Show PageRank node decomposition in the explorer (#507)

Summary:
This commit hooks up the PageRank table to the PageRank node
decomposition developed previously. The new cred explorer displays one
entry per contribution to a node’s cred (i.e., one entry per in-edge,
per out-edge, and per synthetic loop), listing the proportion of the
node’s cred that is provided by this contribution. This makes it easy to
observe facts like, “90% of this issue’s cred is due to being written by
a particular author”.

Paired with @decentralion.

Test Plan:
Unit tests added; run `yarn travis`.

wchargin-branch: pagerank-table-node-decomposition
This commit is contained in:
William Chargin 2018-07-23 10:42:40 -07:00 committed by GitHub
parent bb7b538f44
commit ab0fa81a40
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 750 additions and 599 deletions

View File

@ -7,11 +7,12 @@ import LocalStore from "./LocalStore";
import {createPluginAdapter as createGithubAdapter} from "../../plugins/github/pluginAdapter";
import {createPluginAdapter as createGitAdapter} from "../../plugins/git/pluginAdapter";
import {Graph} from "../../core/graph";
import {pagerank, type NodeDistribution} from "../../core/attribution/pagerank";
import {pagerank} from "../../core/attribution/pagerank";
import {PagerankTable} from "./PagerankTable";
import type {PluginAdapter} from "../pluginAdapter";
import {type EdgeEvaluator} from "../../core/attribution/pagerank";
import {WeightConfig} from "./WeightConfig";
import type {PagerankNodeDecomposition} from "../../core/attribution/pagerankNodeDecomposition";
import * as NullUtil from "../../util/null";
@ -26,13 +27,14 @@ type State = {
+nodeCount: number,
+edgeCount: number,
|},
+pagerankResult: ?NodeDistribution,
+pnd: ?PagerankNodeDecomposition,
|},
edgeEvaluator: ?EdgeEvaluator,
};
const REPO_OWNER_KEY = "repoOwner";
const REPO_NAME_KEY = "repoName";
const MAX_ENTRIES_PER_LIST = 100;
export default class App extends React.Component<Props, State> {
constructor(props: Props) {
@ -40,7 +42,7 @@ export default class App extends React.Component<Props, State> {
this.state = {
repoOwner: "",
repoName: "",
data: {graphWithMetadata: null, pagerankResult: null},
data: {graphWithMetadata: null, pnd: null},
edgeEvaluator: null,
};
}
@ -54,7 +56,7 @@ export default class App extends React.Component<Props, State> {
render() {
const {edgeEvaluator} = this.state;
const {graphWithMetadata, pagerankResult} = this.state.data;
const {graphWithMetadata, pnd} = this.state.data;
return (
<div style={{maxWidth: "66em", margin: "0 auto", padding: "0 10px"}}>
<header className={css(styles.header)}>
@ -96,10 +98,10 @@ export default class App extends React.Component<Props, State> {
throw new Error("Unexpected null value");
}
const {graph} = graphWithMetadata;
const pagerankResult = pagerank(graph, edgeEvaluator, {
const pnd = pagerank(graph, edgeEvaluator, {
verbose: true,
});
const data = {graphWithMetadata, pagerankResult};
const data = {graphWithMetadata, pnd};
// In case a new graph was loaded while waiting for
// PageRank.
const stomped =
@ -123,9 +125,9 @@ export default class App extends React.Component<Props, State> {
)}
<WeightConfig onChange={(ee) => this.setState({edgeEvaluator: ee})} />
<PagerankTable
graph={NullUtil.map(graphWithMetadata, (x) => x.graph)}
adapters={NullUtil.map(graphWithMetadata, (x) => x.adapters)}
pagerankResult={pagerankResult}
pnd={pnd}
maxEntriesPerList={MAX_ENTRIES_PER_LIST}
/>
</div>
</div>
@ -166,7 +168,7 @@ export default class App extends React.Component<Props, State> {
nodeCount: Array.from(graph.nodes()).length,
edgeCount: Array.from(graph.edges()).length,
},
pagerankResult: null,
pnd: null,
};
this.setState({data});
});

View File

@ -4,29 +4,18 @@ import sortBy from "lodash.sortby";
import React from "react";
import {
Graph,
NodeAddress,
type NodeAddressT,
type Neighbor,
Direction,
type Edge,
EdgeAddress,
type EdgeAddressT,
type NodeAddressT,
EdgeAddress,
NodeAddress,
} from "../../core/graph";
import type {NodeDistribution} from "../../core/attribution/pagerank";
import type {
PagerankNodeDecomposition,
ScoredContribution,
} from "../../core/attribution/pagerankNodeDecomposition";
import type {Contribution} from "../../core/attribution/graphToMarkovChain";
import type {PluginAdapter} from "../pluginAdapter";
const MAX_TABLE_ENTRIES = 100;
type Props = {
pagerankResult: ?NodeDistribution,
graph: ?Graph,
adapters: ?$ReadOnlyArray<PluginAdapter>,
};
type State = {
topLevelFilter: NodeAddressT,
};
import * as NullUtil from "../../util/null";
// TODO: Factor this out and test it (#465)
export function nodeDescription(
@ -74,37 +63,45 @@ function edgeVerb(
}
}
export function neighborVerb(
{node, edge}: Neighbor,
adapters: $ReadOnlyArray<PluginAdapter>
): string {
const forwardVerb = edgeVerb(edge.address, "FORWARD", adapters);
const backwardVerb = edgeVerb(edge.address, "BACKWARD", adapters);
if (edge.src === edge.dst) {
return `${forwardVerb} and ${backwardVerb}`;
} else if (edge.dst === node) {
return forwardVerb;
} else {
return backwardVerb;
}
function scoreDisplay(probability: number) {
const modifiedLogScore = Math.log(probability) + 10;
return modifiedLogScore.toFixed(2);
}
export class PagerankTable extends React.PureComponent<Props, State> {
type SharedProps = {|
+pnd: PagerankNodeDecomposition,
+adapters: $ReadOnlyArray<PluginAdapter>,
+maxEntriesPerList: number,
|};
type PagerankTableProps = {|
+pnd: ?PagerankNodeDecomposition,
+adapters: ?$ReadOnlyArray<PluginAdapter>,
+maxEntriesPerList: number,
|};
type PagerankTableState = {|topLevelFilter: NodeAddressT|};
export class PagerankTable extends React.PureComponent<
PagerankTableProps,
PagerankTableState
> {
constructor() {
super();
this.state = {topLevelFilter: NodeAddress.empty};
}
render() {
if (this.props.graph == null || this.props.adapters == null) {
if (this.props.adapters == null) {
return <p>You must load a graph before seeing PageRank analysis.</p>;
}
if (this.props.pagerankResult == null) {
if (this.props.pnd == null) {
return <p>Please run PageRank to see analysis.</p>;
}
if (this.props.maxEntriesPerList == null) {
throw new Error("maxEntriesPerList not set");
}
return (
<div>
<h2>Contributions</h2>
<h2>PageRank results</h2>
{this.renderFilterSelect()}
{this.renderTable()}
</div>
@ -112,8 +109,8 @@ export class PagerankTable extends React.PureComponent<Props, State> {
}
renderFilterSelect() {
const {graph, pagerankResult, adapters} = this.props;
if (graph == null || pagerankResult == null || adapters == null) {
const {pnd, adapters} = this.props;
if (pnd == null || adapters == null) {
throw new Error("Impossible.");
}
@ -136,7 +133,7 @@ export class PagerankTable extends React.PureComponent<Props, State> {
}
return (
<label>
Filter by contribution type:{" "}
<span>Filter by node type: </span>
<select
value={this.state.topLevelFilter}
onChange={(e) => {
@ -151,11 +148,12 @@ export class PagerankTable extends React.PureComponent<Props, State> {
}
renderTable() {
const {graph, pagerankResult, adapters} = this.props;
if (graph == null || pagerankResult == null || adapters == null) {
const {pnd, adapters, maxEntriesPerList} = this.props;
if (pnd == null || adapters == null || maxEntriesPerList == null) {
throw new Error("Impossible.");
}
const topLevelFilter = this.state.topLevelFilter;
const sharedProps = {pnd, adapters, maxEntriesPerList};
return (
<table
style={{
@ -168,23 +166,17 @@ export class PagerankTable extends React.PureComponent<Props, State> {
>
<thead>
<tr>
<th style={{textAlign: "left"}}>Node</th>
<th style={{textAlign: "right"}}>log(score)</th>
<th style={{textAlign: "left"}}>Description</th>
<th style={{textAlign: "right"}}>Contribution</th>
<th style={{textAlign: "right"}}>Score</th>
</tr>
</thead>
<tbody>
<NodesTables
addresses={
topLevelFilter == null
? Array.from(graph.nodes())
: Array.from(graph.nodes()).filter((node) =>
NodeAddress.hasPrefix(node, topLevelFilter)
)
}
graph={graph}
pagerankResult={pagerankResult}
depth={0}
adapters={adapters}
<NodeRowList
sharedProps={sharedProps}
nodes={Array.from(pnd.keys()).filter((node) =>
NodeAddress.hasPrefix(node, topLevelFilter)
)}
/>
</tbody>
</table>
@ -192,164 +184,227 @@ export class PagerankTable extends React.PureComponent<Props, State> {
}
}
type RTState = {expanded: boolean};
type RTProps = {|
+node: NodeAddressT,
// Present if this RT shows a neighbor (not a top-level node)
+edge: ?Edge,
+graph: Graph,
+pagerankResult: NodeDistribution,
+depth: number,
+adapters: $ReadOnlyArray<PluginAdapter>,
type NodeRowListProps = {|
+nodes: $ReadOnlyArray<NodeAddressT>,
+sharedProps: SharedProps,
|};
class RecursiveTable extends React.PureComponent<RTProps, RTState> {
export class NodeRowList extends React.PureComponent<NodeRowListProps> {
render() {
const {nodes, sharedProps} = this.props;
const {pnd, maxEntriesPerList} = sharedProps;
return (
<React.Fragment>
{sortBy(nodes, (n) => -NullUtil.get(pnd.get(n)).score, (n) => n)
.slice(0, maxEntriesPerList)
.map((node) => (
<NodeRow node={node} key={node} sharedProps={sharedProps} />
))}
</React.Fragment>
);
}
}
type RowState = {|
expanded: boolean,
|};
type NodeRowProps = {|
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class NodeRow extends React.PureComponent<NodeRowProps, RowState> {
constructor() {
super();
this.state = {expanded: false};
}
render() {
const {node, edge, adapters, depth, graph, pagerankResult} = this.props;
const {node, sharedProps} = this.props;
const {pnd, adapters} = sharedProps;
const {expanded} = this.state;
const probability = pagerankResult.get(node);
if (probability == null) {
throw new Error(`no PageRank value for ${NodeAddress.toString(node)}`);
}
const modifiedLogScore = Math.log(probability) + 10;
const edgeVerbString =
edge == null ? null : neighborVerb({node, edge}, adapters);
return [
<tr
key="self"
style={{backgroundColor: `rgba(0,143.4375,0,${1 - 0.9 ** depth})`}}
>
<td style={{display: "flex", alignItems: "flex-start"}}>
<button
const {score} = NullUtil.get(pnd.get(node));
return (
<React.Fragment>
<tr key="self">
<td style={{display: "flex", alignItems: "flex-start"}}>
<button
style={{marginRight: 5}}
onClick={() => {
this.setState(({expanded}) => ({
expanded: !expanded,
}));
}}
>
{expanded ? "\u2212" : "+"}
</button>
<span>{nodeDescription(node, adapters)}</span>
</td>
<td style={{textAlign: "right"}}>{"—"}</td>
<td style={{textAlign: "right"}}>{scoreDisplay(score)}</td>
</tr>
{expanded && (
<ContributionRowList
key="children"
depth={1}
node={node}
sharedProps={sharedProps}
/>
)}
</React.Fragment>
);
}
}
type ContributionRowListProps = {|
+depth: number,
+node: NodeAddressT,
+sharedProps: SharedProps,
|};
export class ContributionRowList extends React.PureComponent<
ContributionRowListProps
> {
render() {
const {depth, node, sharedProps} = this.props;
const {pnd, maxEntriesPerList} = sharedProps;
const {scoredContributions} = NullUtil.get(pnd.get(node));
return (
<React.Fragment>
{scoredContributions
.slice(0, maxEntriesPerList)
.map((sc) => (
<ContributionRow
key={JSON.stringify(sc.contribution.contributor)}
depth={depth}
target={node}
scoredContribution={sc}
sharedProps={sharedProps}
/>
))}
</React.Fragment>
);
}
}
type ContributionRowProps = {|
+depth: number,
+target: NodeAddressT,
+scoredContribution: ScoredContribution,
+sharedProps: SharedProps,
|};
export class ContributionRow extends React.PureComponent<
ContributionRowProps,
RowState
> {
constructor() {
super();
this.state = {expanded: false};
}
render() {
const {
sharedProps,
target,
depth,
scoredContribution: {
contribution,
source,
sourceScore,
contributionScore,
},
} = 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);
return (
<React.Fragment>
<tr
key="self"
style={{backgroundColor: `rgba(0,143.4375,0,${1 - 0.9 ** depth})`}}
>
<td style={{display: "flex", alignItems: "flex-start"}}>
<button
style={{
marginRight: 5,
marginLeft: 15 * depth,
}}
onClick={() => {
this.setState(({expanded}) => ({
expanded: !expanded,
}));
}}
>
{expanded ? "\u2212" : "+"}
</button>
<ContributionView contribution={contribution} adapters={adapters} />
</td>
<td style={{textAlign: "right"}}>{contributionPercent}%</td>
<td style={{textAlign: "right"}}>{scoreDisplay(sourceScore)}</td>
</tr>
{expanded && (
<ContributionRowList
key="children"
depth={depth + 1}
node={source}
sharedProps={sharedProps}
/>
)}
</React.Fragment>
);
}
}
export class ContributionView extends React.PureComponent<{|
+contribution: Contribution,
+adapters: $ReadOnlyArray<PluginAdapter>,
|}> {
render() {
const {contribution, adapters} = this.props;
function Badge({children}) {
return (
// The outer <span> acts as a strut to ensure that the badge
// takes up a full line height, even though its text is smaller.
<span>
<span
style={{
marginRight: 5,
marginLeft: 15 * depth,
}}
onClick={() => {
this.setState(({expanded}) => ({
expanded: !expanded,
}));
textTransform: "uppercase",
fontWeight: 700,
fontSize: "smaller",
}}
>
{expanded ? "\u2212" : "+"}
</button>
<span>
{edgeVerbString != null && (
<React.Fragment>
<span
style={{
display: "inline-block",
textTransform: "uppercase",
fontWeight: 700,
fontSize: "smaller",
}}
>
{edgeVerbString}
</span>{" "}
</React.Fragment>
)}
{nodeDescription(node, adapters)}
{children}
</span>
</td>
<td style={{textAlign: "right"}}>{modifiedLogScore.toFixed(2)}</td>
</tr>,
expanded && (
<NeighborsTables
key="children"
neighbors={Array.from(
graph.neighbors(node, {
direction: Direction.ANY,
nodePrefix: NodeAddress.empty,
edgePrefix: EdgeAddress.empty,
})
)}
graph={graph}
pagerankResult={pagerankResult}
depth={depth + 1}
adapters={adapters}
/>
),
];
}
}
type NodesTablesProps = {|
+addresses: $ReadOnlyArray<NodeAddressT>,
+graph: Graph,
+pagerankResult: NodeDistribution,
+depth: number,
+adapters: $ReadOnlyArray<PluginAdapter>,
|};
class NodesTables extends React.PureComponent<NodesTablesProps> {
render() {
const {addresses, graph, pagerankResult, depth, adapters} = this.props;
return sortBy(
addresses,
(x) => {
const p = pagerankResult.get(x);
if (p == null) {
throw new Error(`No pagerank result for ${NodeAddress.toString(x)}`);
}
return -p;
},
(x) => x
)
.slice(0, MAX_TABLE_ENTRIES)
.map((address) => (
<RecursiveTable
depth={depth}
node={address}
edge={null}
graph={graph}
pagerankResult={pagerankResult}
key={address}
adapters={adapters}
/>
));
}
}
type NeighborsTablesProps = {|
+neighbors: $ReadOnlyArray<Neighbor>,
+graph: Graph,
+pagerankResult: NodeDistribution,
+depth: number,
+adapters: $ReadOnlyArray<PluginAdapter>,
|};
class NeighborsTables extends React.PureComponent<NeighborsTablesProps> {
render() {
const {neighbors, graph, pagerankResult, depth, adapters} = this.props;
return sortBy(
neighbors,
({node}) => {
const p = pagerankResult.get(node);
if (p == null) {
throw new Error(
`No pagerank result for ${NodeAddress.toString(node)}`
);
}
return -p;
},
({edge}) => edge
)
.slice(0, MAX_TABLE_ENTRIES)
.map(({node, edge}) => (
<RecursiveTable
depth={depth}
node={node}
edge={edge}
graph={graph}
pagerankResult={pagerankResult}
key={edge.address}
adapters={adapters}
/>
));
</span>
);
}
const {contributor} = contribution;
switch (contributor.type) {
case "SYNTHETIC_LOOP":
return <Badge>synthetic loop</Badge>;
case "IN_EDGE":
return (
<span>
<Badge>
{edgeVerb(contributor.edge.address, "BACKWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(contributor.edge.src, adapters)}</span>
</span>
);
case "OUT_EDGE":
return (
<span>
<Badge>
{edgeVerb(contributor.edge.address, "FORWARD", adapters)}
</Badge>{" "}
<span>{nodeDescription(contributor.edge.dst, adapters)}</span>
</span>
);
default:
throw new Error((contributor.type: empty));
}
}
}

View File

@ -1,22 +1,32 @@
// @flow
import React from "react";
import {mount, shallow} from "enzyme";
import {shallow} from "enzyme";
import enzymeToJSON from "enzyme-to-json";
import {PagerankTable, nodeDescription, neighborVerb} from "./PagerankTable";
import {
PagerankTable,
NodeRowList,
NodeRow,
ContributionRowList,
ContributionRow,
ContributionView,
} from "./PagerankTable";
import {pagerank} from "../../core/attribution/pagerank";
import sortBy from "lodash.sortby";
import {type Contribution} from "../../core/attribution/graphToMarkovChain";
import * as NullUtil from "../../util/null";
import {
Graph,
type NodeAddressT,
Direction,
NodeAddress,
EdgeAddress,
} from "../../core/graph";
require("../testUtil").configureEnzyme();
const COLUMNS = () => ["Description", "Contribution", "Score"];
function example() {
const graph = new Graph();
const nodes = {
@ -105,21 +115,15 @@ function example() {
},
];
const pagerankResult = pagerank(graph, (_unused_Edge) => ({
const pnd = pagerank(graph, (_unused_Edge) => ({
toWeight: 1,
froWeight: 1,
}));
return {adapters, nodes, edges, graph, pagerankResult};
return {adapters, nodes, edges, graph, pnd};
}
describe("app/credExplorer/PagerankTable", () => {
function verifyNoAdapterWarning() {
expect(console.warn).toHaveBeenCalledWith("No adapter for NodeAddress[]");
expect(console.warn).toHaveBeenCalledTimes(1);
// $ExpectFlowError
console.warn = jest.fn();
}
beforeEach(() => {
// $ExpectFlowError
console.error = jest.fn();
@ -131,262 +135,467 @@ describe("app/credExplorer/PagerankTable", () => {
expect(console.error).not.toHaveBeenCalled();
});
describe("rendering with incomplete props", () => {
describe("PagerankTable", () => {
it("renders expected message with null props", () => {
const element = shallow(
<PagerankTable pagerankResult={null} graph={null} adapters={null} />
<PagerankTable pnd={null} adapters={null} maxEntriesPerList={1} />
);
expect(enzymeToJSON(element)).toMatchSnapshot();
});
it("renders with just pagerankResult", () => {
const {pagerankResult} = example();
// No snapshot since this should never actually happen
shallow(
<PagerankTable
pagerankResult={pagerankResult}
graph={null}
adapters={null}
/>
);
});
it("renders with just graph", () => {
const {graph} = example();
// No snapshot since this should never actually happen
shallow(
<PagerankTable pagerankResult={null} graph={graph} adapters={null} />
);
});
it("renders with just adapters", () => {
it("renders expected message with just adapters", () => {
const {adapters} = example();
// No snapshot since this should never actually happen
shallow(
<PagerankTable pagerankResult={null} graph={null} adapters={adapters} />
);
});
it("renders expected message when there's no pagerank", () => {
const {graph, adapters} = example();
const element = shallow(
<PagerankTable
pagerankResult={null}
graph={graph}
adapters={adapters}
/>
<PagerankTable pnd={null} adapters={adapters} maxEntriesPerList={1} />
);
expect(enzymeToJSON(element)).toMatchSnapshot();
});
it("throws an error if maxEntriesPerList not set", () => {
const {pnd, adapters} = example();
expect(() =>
shallow(
<PagerankTable
pnd={pnd}
adapters={adapters}
// $ExpectFlowError
maxEntriesPerList={null}
/>
)
).toThrowError("maxEntriesPerList");
});
it("renders thead column order properly", () => {
const {pnd, adapters} = example();
const element = shallow(
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
);
const th = element.find("thead th");
const columnNames = th.map((t) => t.text());
expect(columnNames).toEqual(COLUMNS());
});
describe("has a filter select", () => {
function setup() {
const {pnd, adapters} = example();
const element = shallow(
<PagerankTable pnd={pnd} adapters={adapters} maxEntriesPerList={1} />
);
const label = element.find("label");
const options = label.find("option");
return {pnd, adapters, element, label, options};
}
it("with expected label text", () => {
const {label} = setup();
const filterText = label
.find("span")
.first()
.text();
expect(filterText).toMatchSnapshot();
});
it("with expected option groups", () => {
const {options} = setup();
const optionsJSON = options.map((o) => ({
valueString: NodeAddress.toString(o.prop("value")),
style: o.prop("style"),
text: o.text(),
}));
expect(optionsJSON).toMatchSnapshot();
});
it("with the ability to filter nodes passed to NodeRowList", () => {
const {element, options} = setup();
const option1 = options.at(1);
const value = option1.prop("value");
expect(value).not.toEqual(NodeAddress.empty);
const previousNodes = element.find("NodeRowList").prop("nodes");
expect(
previousNodes.every((n) => NodeAddress.hasPrefix(n, value))
).toBe(false);
element.find("select").simulate("change", {target: {value}});
const actualNodes = element.find("NodeRowList").prop("nodes");
expect(actualNodes.every((n) => NodeAddress.hasPrefix(n, value))).toBe(
true
);
expect(actualNodes).not.toHaveLength(0);
});
});
describe("creates a NodeRowList", () => {
function setup() {
const {adapters, pnd} = example();
const maxEntriesPerList = 1;
const element = shallow(
<PagerankTable
pnd={pnd}
adapters={adapters}
maxEntriesPerList={maxEntriesPerList}
/>
);
const nrl = element.find("NodeRowList");
return {adapters, pnd, element, nrl, maxEntriesPerList};
}
it("with the correct SharedProps", () => {
const {nrl, adapters, pnd, maxEntriesPerList} = setup();
const expectedSharedProps = {adapters, pnd, maxEntriesPerList};
expect(nrl.prop("sharedProps")).toEqual(expectedSharedProps);
});
it("including all nodes by default", () => {
const {nrl, pnd} = setup();
const expectedNodes = Array.from(pnd.keys());
expect(nrl.prop("nodes")).toEqual(expectedNodes);
});
});
});
describe("full rendering", () => {
function exampleRender() {
const {nodes, edges, adapters, graph, pagerankResult} = example();
const element = mount(
<PagerankTable
pagerankResult={pagerankResult}
graph={graph}
adapters={adapters}
describe("NodeRowList", () => {
function sortedByScore(nodes: $ReadOnlyArray<NodeAddressT>, pnd) {
return sortBy(nodes, (node) => -NullUtil.get(pnd.get(node)).score);
}
function setup(maxEntriesPerList: number = 100000) {
const {adapters, pnd} = example();
const nodes = sortedByScore(Array.from(pnd.keys()), pnd)
.reverse() // ascending order!
.filter((x) =>
NodeAddress.hasPrefix(x, NodeAddress.fromParts(["foo"]))
);
expect(nodes).not.toHaveLength(0);
expect(nodes).not.toHaveLength(1);
expect(nodes).not.toHaveLength(pnd.size);
const sharedProps = {adapters, pnd, maxEntriesPerList};
const component = <NodeRowList sharedProps={sharedProps} nodes={nodes} />;
const element = shallow(component);
return {element, adapters, sharedProps, nodes};
}
it("creates `NodeRow`s with the right props", () => {
const {element, nodes, sharedProps} = setup();
const rows = element.find("NodeRow");
expect(rows).toHaveLength(nodes.length);
const rowNodes = rows.map((row) => row.prop("node"));
// Check that we selected the right set of nodes. We'll check
// order in a separate test case.
expect(rowNodes.slice().sort()).toEqual(nodes.slice().sort());
rows.forEach((row) => {
expect(row.prop("sharedProps")).toEqual(sharedProps);
});
});
it("creates up to `maxEntriesPerList` `NodeRow`s", () => {
const maxEntriesPerList = 1;
const {element, nodes, sharedProps} = setup(maxEntriesPerList);
expect(nodes.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("NodeRow");
expect(rows).toHaveLength(maxEntriesPerList);
const rowNodes = rows.map((row) => row.prop("node"));
// Should have selected the right nodes.
expect(rowNodes).toEqual(
sortedByScore(nodes, sharedProps.pnd).slice(0, maxEntriesPerList)
);
});
it("sorts its children by score", () => {
const {
element,
nodes,
sharedProps: {pnd},
} = setup();
expect(nodes).not.toEqual(sortedByScore(nodes, pnd));
const rows = element.find("NodeRow");
const rowNodes = rows.map((row) => row.prop("node"));
expect(rowNodes).toEqual(sortedByScore(rowNodes, pnd));
});
});
describe("NodeRow", () => {
function setup() {
const {pnd, adapters, nodes} = example();
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
const node = nodes.bar1;
const component = <NodeRow node={node} sharedProps={sharedProps} />;
const element = shallow(component);
return {element, node, sharedProps};
}
it("renders the right number of columns", () => {
expect(setup().element.find("td")).toHaveLength(COLUMNS().length);
});
it("renders the node description", () => {
const {element} = setup();
const expectedDescription = 'bar: NodeAddress["bar","a","1"]';
const descriptionColumn = COLUMNS().indexOf("Description");
expect(descriptionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(descriptionColumn)
.find("span")
.text()
).toEqual(expectedDescription);
});
it("renders an empty contribution column", () => {
const {element} = setup();
const contributionColumn = COLUMNS().indexOf("Contribution");
expect(contributionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(contributionColumn)
.text()
).toEqual("—");
});
it("renders a score column with the node's log-score", () => {
const {element, sharedProps, node} = setup();
const {score: rawScore} = NullUtil.get(sharedProps.pnd.get(node));
const expectedScore = (Math.log(rawScore) + 10).toFixed(2);
const contributionColumn = COLUMNS().indexOf("Score");
expect(contributionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(contributionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", () => {
const {element} = setup();
expect(element.find("ContributionRowList")).toHaveLength(0);
});
it('has a working "expand" button', () => {
const {element, sharedProps, node} = setup();
expect(element.find("button").text()).toEqual("+");
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ContributionRowList");
expect(crl).toHaveLength(1);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
expect(crl.prop("depth")).toBe(1);
expect(crl.prop("node")).toBe(node);
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ContributionRowList")).toHaveLength(0);
});
});
describe("ContributionRowList", () => {
function setup(maxEntriesPerList: number = 100000) {
const {adapters, pnd, nodes} = example();
const depth = 2;
const node = nodes.bar1;
const sharedProps = {adapters, pnd, maxEntriesPerList};
const component = (
<ContributionRowList
depth={depth}
node={node}
sharedProps={sharedProps}
/>
);
verifyNoAdapterWarning();
const select = element.find("select");
expect(select).toHaveLength(1);
return {nodes, edges, adapters, graph, pagerankResult, element, select};
const element = shallow(component);
return {element, depth, node, sharedProps};
}
it("full render doesn't crash or error", () => {
example();
it("creates `ContributionRow`s with the right props", () => {
const {element, depth, node, sharedProps} = setup();
const contributions = NullUtil.get(sharedProps.pnd.get(node))
.scoredContributions;
const rows = element.find("ContributionRow");
expect(rows).toHaveLength(contributions.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) => ({
depth,
sharedProps,
target: node,
scoredContribution: sc,
}))
);
});
it("limits the number of rows by `maxEntriesPerList`", () => {
const maxEntriesPerList = 1;
const {element, node, sharedProps} = setup(maxEntriesPerList);
const contributions = NullUtil.get(sharedProps.pnd.get(node))
.scoredContributions;
expect(contributions.length).toBeGreaterThan(maxEntriesPerList);
const rows = element.find("ContributionRow");
expect(rows).toHaveLength(maxEntriesPerList);
const rowContributions = rows.map((row) =>
row.prop("scoredContribution")
);
// Should have selected the right nodes.
expect(rowContributions).toEqual(
contributions.slice(0, maxEntriesPerList)
);
});
});
describe("tables", () => {
function expectColumnCorrect(
element: *,
name: string,
tdToExpected: (x: *) => string,
addressToExpected: (NodeAddressT) => string
) {
const header = element.find("th");
const headerTexts = header.map((x) => x.text());
const headerIndex = headerTexts.indexOf(name);
if (headerIndex === -1) {
throw new Error("Could not find column: " + name);
}
const tables = element.find("RecursiveTable");
const actual = tables.map((x) =>
tdToExpected(x.find("td").at(headerIndex))
describe("ContributionRow", () => {
function setup() {
const {pnd, adapters, nodes} = example();
const sharedProps = {adapters, pnd, maxEntriesPerList: 123};
const target = nodes.bar1;
const {scoredContributions} = NullUtil.get(pnd.get(target));
const alphaContributions = scoredContributions.filter(
(sc) => sc.source === nodes.fooAlpha
);
expect(alphaContributions).toHaveLength(1);
const contribution = alphaContributions[0];
const {source} = contribution;
const depth = 2;
const component = (
<ContributionRow
depth={depth}
target={target}
scoredContribution={contribution}
sharedProps={sharedProps}
/>
);
const element = shallow(component);
return {element, depth, target, source, contribution, sharedProps};
}
it("renders the right number of columns", () => {
expect(setup().element.find("td")).toHaveLength(COLUMNS().length);
});
it("has proper depth-based styling", () => {
const {element} = setup();
expect({
buttonStyle: element.find("button").prop("style"),
trStyle: element.find("tr").prop("style"),
}).toMatchSnapshot();
});
it("renders the source view", () => {
const {element, sharedProps, contribution} = setup();
const descriptionColumn = COLUMNS().indexOf("Description");
expect(descriptionColumn).not.toEqual(-1);
const view = element
.find("td")
.at(descriptionColumn)
.find("ContributionView");
expect(view).toHaveLength(1);
expect(view.props()).toEqual({
adapters: sharedProps.adapters,
contribution: contribution.contribution,
});
});
it("renders the contribution percentage", () => {
const {element, contribution, sharedProps, target} = setup();
const contributionColumn = COLUMNS().indexOf("Contribution");
expect(contributionColumn).not.toEqual(-1);
const proportion =
contribution.contributionScore /
NullUtil.get(sharedProps.pnd.get(target)).score;
expect(proportion).toBeGreaterThan(0.0);
expect(proportion).toBeLessThan(1.0);
const expectedText = (proportion * 100).toFixed(2) + "%";
expect(
element
.find("td")
.at(contributionColumn)
.text()
).toEqual(expectedText);
});
it("renders a score column with the source's log-score", () => {
const {element, contribution} = setup();
const expectedScore = (Math.log(contribution.sourceScore) + 10).toFixed(
2
);
const contributionColumn = COLUMNS().indexOf("Score");
expect(contributionColumn).not.toEqual(-1);
expect(
element
.find("td")
.at(contributionColumn)
.text()
).toEqual(expectedScore);
});
it("does not render children by default", () => {
const {element} = setup();
expect(element.find("ContributionRowList")).toHaveLength(0);
});
it('has a working "expand" button', () => {
const {element, depth, sharedProps, source} = setup();
expect(element.find("button").text()).toEqual("+");
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("\u2212");
const crl = element.find("ContributionRowList");
expect(crl).toHaveLength(1);
expect(crl).not.toHaveLength(0);
expect(crl.prop("sharedProps")).toEqual(sharedProps);
expect(crl.prop("depth")).toBe(depth + 1);
expect(crl.prop("node")).toBe(source);
element.find("button").simulate("click");
expect(element.find("button").text()).toEqual("+");
expect(element.find("ContributionRowList")).toHaveLength(0);
});
});
describe("ContributionView", () => {
function setup() {
const {pnd, adapters, nodes} = example();
const {scoredContributions} = NullUtil.get(pnd.get(nodes.bar1));
const contributions = scoredContributions.map((sc) => sc.contribution);
function contributionByType(t) {
return NullUtil.get(
contributions.filter((c) => c.contributor.type === t)[0],
`Couldn't find contribution for type ${t}`
);
const expected = tables.map((x) => addressToExpected(x.prop("node")));
expect(actual).toEqual(expected);
}
describe("top-level", () => {
it("are sorted by score", () => {
const {element, graph, pagerankResult} = exampleRender();
const rows = element.find("RecursiveTable");
expect(rows).toHaveLength(Array.from(graph.nodes()).length);
const scores = rows.map((x) => pagerankResult.get(x.prop("node")));
expect(scores.every((x) => x != null)).toBe(true);
expect(scores).toEqual(sortBy(scores).reverse());
});
it("has a node description column", () => {
const {element, adapters} = exampleRender();
expectColumnCorrect(
element,
"Node",
(td) => td.find("span").text(),
(address) => nodeDescription(address, adapters)
);
verifyNoAdapterWarning();
});
it("has a log score column", () => {
const {element, pagerankResult} = exampleRender();
expectColumnCorrect(
element,
"log(score)",
(td) => td.text(),
(address) => {
const probability = pagerankResult.get(address);
if (probability == null) {
throw new Error(address);
}
const modifiedLogScore = Math.log(probability) + 10;
return modifiedLogScore.toFixed(2);
}
);
});
});
describe("sub-tables", () => {
it("have depth-based styling", () => {
const {element} = exampleRender();
const getLevel = (level) => {
const rt = element.find("RecursiveTable").at(level);
const button = rt.find("button").first();
return {rt, button};
};
getLevel(0).button.simulate("click");
getLevel(1).button.simulate("click");
const f = ({rt, button}) => ({
row: rt
.find("tr")
.first()
.prop("style"),
button: button.prop("style"),
});
expect([0, 1, 2].map((x) => f(getLevel(x)))).toMatchSnapshot();
});
it("display extra information about edges", () => {
const {element, nodes, graph, adapters} = exampleRender();
const getLevel = (level) => {
const rt = element.find("RecursiveTable").at(level);
const button = rt.find("button").first();
return {rt, button};
};
getLevel(0).button.simulate("click");
const nt = element.find("NeighborsTables");
expect(nt).toHaveLength(1);
const expectedNeighbors = Array.from(
graph.neighbors(nodes.bar1, {
direction: Direction.ANY,
nodePrefix: NodeAddress.empty,
edgePrefix: EdgeAddress.empty,
})
);
expect(nt.prop("neighbors")).toEqual(expectedNeighbors);
const subTables = nt.find("RecursiveTable");
expect(subTables).toHaveLength(expectedNeighbors.length);
const actualEdgeVerbs = subTables.map((x) =>
x
.find("span")
.children()
.find("span")
.text()
);
const expectedEdgeVerbs = subTables.map((x) => {
const edge = x.prop("edge");
const node = x.prop("node");
return neighborVerb({edge, node}, adapters);
});
expect(actualEdgeVerbs).toEqual(expectedEdgeVerbs);
const actualFullDescriptions = subTables.map((x) =>
x
.find("span")
.first()
.text()
);
const expectedFullDescriptions = subTables.map((x) => {
const edge = x.prop("edge");
const node = x.prop("node");
const nd = nodeDescription(node, adapters);
const ev = neighborVerb({node, edge}, adapters);
return `${ev} ${nd}`;
});
expect(actualFullDescriptions).toEqual(expectedFullDescriptions);
expect(actualFullDescriptions).toMatchSnapshot();
});
});
it("button toggles between +/- and adds sub-RecursiveTable", () => {
const {element} = exampleRender();
const rt = () => element.find("RecursiveTable").first();
const button = rt().find("button");
expect(button).toEqual(expect.anything());
expect(button.text()).toEqual("+");
expect(rt().find("NeighborsTables")).toHaveLength(0);
button.simulate("click");
expect(button.text()).toEqual("\u2212");
expect(rt().find("NeighborsTables")).toHaveLength(1);
button.simulate("click");
expect(button.text()).toEqual("+");
expect(rt().find("NeighborsTables")).toHaveLength(0);
});
const inContribution = contributionByType("IN_EDGE");
const outContribution = contributionByType("OUT_EDGE");
const syntheticContribution = contributionByType("SYNTHETIC_LOOP");
function cvForContribution(contribution: Contribution) {
return shallow(
<ContributionView adapters={adapters} contribution={contribution} />
);
}
return {
adapters,
contributions,
pnd,
cvForContribution,
inContribution,
outContribution,
syntheticContribution,
};
}
it("always renders exactly one `Badge`", () => {
const {
cvForContribution,
inContribution,
outContribution,
syntheticContribution,
} = setup();
for (const contribution of [
syntheticContribution,
inContribution,
outContribution,
]) {
expect(cvForContribution(contribution).find("Badge")).toHaveLength(1);
}
});
describe("filter selector", () => {
it("has the correct options", () => {
const {select} = exampleRender();
const options = select.children();
expect(options.every("option")).toBe(true);
const results = options.map((x) => ({
valueString: NodeAddress.toString(x.prop("value")),
style: x.prop("style"),
text: x.text(),
}));
expect(results).toMatchSnapshot();
});
function selectFilterByName(select, name) {
const option = select.children().filterWhere((x) => x.text() === name);
if (option.length !== 1) {
throw new Error(`ambiguous select, got ${option.length} options`);
}
const value = option.prop("value");
select.simulate("change", {target: {value}});
}
it("plugin-level filter with no nodes works", () => {
const {select, element} = exampleRender();
expect(element.find("tbody tr")).not.toHaveLength(0);
selectFilterByName(select, "unused");
expect(element.find("tbody tr")).toHaveLength(0);
});
it("type-level filter with some nodes works", () => {
const {select, element} = exampleRender();
selectFilterByName(select, "\u2003beta");
const rt = element.find("RecursiveTable");
expect(rt).toHaveLength(1);
expect(rt.prop("node")).toEqual(example().nodes.fooBeta);
});
it("filter doesn't apply to sub-tables", () => {
const {select, element} = exampleRender();
selectFilterByName(select, "\u2003beta");
const rt = element.find("RecursiveTable");
expect(rt).toHaveLength(1);
const button = rt.find("button");
expect(button).toHaveLength(1);
button.simulate("click");
const rts = element.find("RecursiveTable");
expect(rts).toHaveLength(2);
const subRt = rts.last();
expect(subRt.prop("node")).toEqual(example().nodes.fooAlpha);
});
it("for inward contributions, renders a `Badge` and description", () => {
const {cvForContribution, inContribution} = setup();
const view = cvForContribution(inContribution);
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", () => {
const {cvForContribution, outContribution} = setup();
const view = cvForContribution(outContribution);
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`", () => {
const {cvForContribution, syntheticContribution} = setup();
const view = cvForContribution(syntheticContribution);
expect(view.find("span")).toHaveLength(0);
expect(
view
.find("Badge")
.children()
.text()
).toEqual("synthetic loop");
});
});
});

View File

@ -1,6 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app/credExplorer/PagerankTable full rendering filter selector has the correct options 1`] = `
exports[`app/credExplorer/PagerankTable ContributionRow has proper depth-based styling 1`] = `
Object {
"buttonStyle": Object {
"marginLeft": 30,
"marginRight": 5,
},
"trStyle": Object {
"backgroundColor": "rgba(0,143.4375,0,0.18999999999999995)",
},
}
`;
exports[`app/credExplorer/PagerankTable PagerankTable has a filter select with expected label text 1`] = `"Filter by node type: "`;
exports[`app/credExplorer/PagerankTable PagerankTable has a filter select with expected option groups 1`] = `
Array [
Object {
"style": undefined,
@ -53,54 +67,13 @@ Array [
]
`;
exports[`app/credExplorer/PagerankTable full rendering tables sub-tables display extra information about edges 1`] = `
Array [
"bars and is barred by bar: NodeAddress[\\"bar\\",\\"a\\",\\"1\\"]",
"bars xox node!",
"bars xox node!",
"is fooed by foo: NodeAddress[\\"foo\\",\\"a\\",\\"1\\"]",
]
`;
exports[`app/credExplorer/PagerankTable full rendering tables sub-tables have depth-based styling 1`] = `
Array [
Object {
"button": Object {
"marginLeft": 0,
"marginRight": 5,
},
"row": Object {
"backgroundColor": "rgba(0,143.4375,0,0)",
},
},
Object {
"button": Object {
"marginLeft": 15,
"marginRight": 5,
},
"row": Object {
"backgroundColor": "rgba(0,143.4375,0,0.09999999999999998)",
},
},
Object {
"button": Object {
"marginLeft": 30,
"marginRight": 5,
},
"row": Object {
"backgroundColor": "rgba(0,143.4375,0,0.18999999999999995)",
},
},
]
`;
exports[`app/credExplorer/PagerankTable rendering with incomplete props renders expected message when there's no pagerank 1`] = `
exports[`app/credExplorer/PagerankTable PagerankTable renders expected message with just adapters 1`] = `
<p>
Please run PageRank to see analysis.
</p>
`;
exports[`app/credExplorer/PagerankTable rendering with incomplete props renders expected message with null props 1`] = `
exports[`app/credExplorer/PagerankTable PagerankTable renders expected message with null props 1`] = `
<p>
You must load a graph before seeing PageRank analysis.
</p>

View File

@ -1,59 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`core/attribution/pagerank respects explicit arguments 1`] = `
Array [
Object {
"parts": Array [
"dst",
],
"probability": 0.25,
},
Object {
"parts": Array [
"isolated",
],
"probability": 0.25,
},
Object {
"parts": Array [
"loop",
],
"probability": 0.25,
},
Object {
"parts": Array [
"src",
],
"probability": 0.25,
},
]
`;
exports[`core/attribution/pagerank snapshots as expected on the advanced graph 1`] = `
Array [
Object {
"parts": Array [
"dst",
],
"probability": 0.4999999999687968,
},
Object {
"parts": Array [
"isolated",
],
"probability": 0.25,
},
Object {
"parts": Array [
"loop",
],
"probability": 0.25000000000000017,
},
Object {
"parts": Array [
"src",
],
"probability": 3.120317183596679e-11,
},
]
`;

View File

@ -2,16 +2,20 @@
import {type Edge, Graph} from "../graph";
import {
type NodeDistribution,
distributionToNodeDistribution,
createContributions,
createOrderedSparseMarkovChain,
type EdgeWeight,
} from "./graphToMarkovChain";
import {
decompose,
type PagerankNodeDecomposition,
} from "./pagerankNodeDecomposition";
import {findStationaryDistribution} from "./markovChain";
export type {NodeDistribution} from "./graphToMarkovChain";
export type {PagerankNodeDecomposition} from "./pagerankNodeDecomposition";
export type PagerankOptions = {|
+selfLoopWeight?: number,
+verbose?: boolean,
@ -35,7 +39,7 @@ export function pagerank(
graph: Graph,
edgeWeight: EdgeEvaluator,
options?: PagerankOptions
): NodeDistribution {
): PagerankNodeDecomposition {
const fullOptions = {
...defaultOptions(),
...(options || {}),
@ -51,5 +55,6 @@ export function pagerank(
convergenceThreshold: fullOptions.convergenceThreshold,
maxIterations: fullOptions.maxIterations,
});
return distributionToNodeDistribution(osmc.nodeOrder, distribution);
const pi = distributionToNodeDistribution(osmc.nodeOrder, distribution);
return decompose(pi, contributions);
}

View File

@ -1,34 +0,0 @@
// @flow
import {pagerank} from "./pagerank";
import {NodeAddress} from "../graph";
import {advancedGraph} from "../graphTestUtil";
function snapshotPagerankResult(result) {
const prTotal = Array.from(result.values()).reduce((a, b) => a + b, 0);
expect(prTotal).toBeCloseTo(1.0, 1e-9);
const partsToProbability = [];
const sortedKeys = Array.from(result.keys()).sort();
for (const key of sortedKeys) {
const probability = result.get(key);
const parts = NodeAddress.toParts((key: any));
partsToProbability.push({parts, probability});
}
expect(partsToProbability).toMatchSnapshot();
}
describe("core/attribution/pagerank", () => {
function edgeWeight(_unused_edge) {
return {toWeight: 1, froWeight: 0};
}
it("snapshots as expected on the advanced graph", () => {
const pagerankResult = pagerank(advancedGraph().graph1(), edgeWeight);
snapshotPagerankResult(pagerankResult);
});
it("respects explicit arguments", () => {
const pagerankResult = pagerank(advancedGraph().graph1(), edgeWeight, {
maxIterations: 0,
});
snapshotPagerankResult(pagerankResult);
});
});