Recreate Pagerank Table (#466)

Ports #265 to the v3 branch, along with some tweaks:
- Only display log score, and normalize them by adding 10 (so that most
are non-negative)
- Change colors to a soothing green
- Improve display, e.g. make overflowing node description text wrap
within the row

Implements most of the tests requested in #269

Test plan: Many unit tests added

Paired with @wchargin
This commit is contained in:
Dandelion Mané 2018-06-30 14:10:18 -07:00 committed by GitHub
parent 72be58a5c0
commit 65b5babac4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 695 additions and 0 deletions

View File

@ -0,0 +1,252 @@
// @flow
import sortBy from "lodash.sortby";
import React from "react";
import {
Graph,
NodeAddress,
type NodeAddressT,
Direction,
EdgeAddress,
} from "../../core/graph";
import type {PagerankResult} from "../../core/attribution/pagerank";
import type {PluginAdapter} from "../pluginAdapter";
const MAX_TABLE_ENTRIES = 100;
type Props = {
pagerankResult: ?PagerankResult,
graph: ?Graph,
adapters: ?$ReadOnlyArray<PluginAdapter>,
};
type State = {
topLevelFilter: NodeAddressT,
};
// TODO: Factor this out and test it (#465)
export function nodeDescription(
address: NodeAddressT,
adapters: $ReadOnlyArray<PluginAdapter>
): string {
const adapter = adapters.find((adapter) =>
NodeAddress.hasPrefix(address, adapter.nodePrefix())
);
if (adapter == null) {
const result = NodeAddress.toString(address);
console.warn(`No adapter for ${result}`);
return result;
}
try {
return adapter.renderer().nodeDescription(address);
} catch (e) {
const result = NodeAddress.toString(address);
console.error(`Error getting description for ${result}: ${e.message}`);
return result;
}
}
export class PagerankTable extends React.Component<Props, State> {
constructor() {
super();
this.state = {topLevelFilter: NodeAddress.empty};
}
render() {
if (this.props.graph == null || this.props.adapters == null) {
return <p>You must load a graph before seeing PageRank analysis.</p>;
}
if (this.props.pagerankResult == null) {
return <p>Please run PageRank to see analysis.</p>;
}
return (
<div>
<h2>Contributions</h2>
{this.renderFilterSelect()}
{this.renderTable()}
</div>
);
}
renderFilterSelect() {
const {graph, pagerankResult, adapters} = this.props;
if (graph == null || pagerankResult == null || adapters == null) {
throw new Error("Impossible.");
}
function optionGroup(adapter: PluginAdapter) {
const header = (
<option
key={adapter.nodePrefix()}
value={adapter.nodePrefix()}
style={{fontWeight: "bold"}}
>
{adapter.name()}
</option>
);
const entries = adapter.nodeTypes().map((type) => (
<option key={type.prefix} value={type.prefix}>
{"\u2003" + type.name}
</option>
));
return [header, ...entries];
}
return (
<label>
Filter by contribution type:{" "}
<select
value={this.state.topLevelFilter}
onChange={(e) => {
this.setState({topLevelFilter: e.target.value});
}}
>
<option value={NodeAddress.empty}>Show all</option>
{sortBy(adapters, (a) => a.name()).map(optionGroup)}
</select>
</label>
);
}
renderTable() {
const {graph, pagerankResult, adapters} = this.props;
if (graph == null || pagerankResult == null || adapters == null) {
throw new Error("Impossible.");
}
const topLevelFilter = this.state.topLevelFilter;
return (
<table style={{borderCollapse: "collapse", marginTop: 10}}>
<thead>
<tr>
<th style={{textAlign: "left"}}>Node</th>
<th style={{textAlign: "right"}}>log(score)</th>
</tr>
</thead>
<tbody>
<RecursiveTables
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}
/>
</tbody>
</table>
);
}
}
type RTState = {expanded: boolean};
type RTProps = {|
+address: NodeAddressT,
+graph: Graph,
+pagerankResult: PagerankResult,
+depth: number,
+adapters: $ReadOnlyArray<PluginAdapter>,
|};
class RecursiveTable extends React.Component<RTProps, RTState> {
constructor() {
super();
this.state = {expanded: false};
}
render() {
const {address, adapters, depth, graph, pagerankResult} = this.props;
const {expanded} = this.state;
const probability = pagerankResult.get(address);
if (probability == null) {
throw new Error(`no PageRank value for ${NodeAddress.toString(address)}`);
}
const modifiedLogScore = Math.log(probability) + 10;
return [
<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>
<span>{nodeDescription(address, adapters)}</span>
</td>
<td style={{textAlign: "right"}}>{modifiedLogScore.toFixed(2)}</td>
</tr>,
expanded && (
<RecursiveTables
key="children"
addresses={Array.from(
new Set( // deduplicate same node reached by several edges
Array.from(
graph.neighbors(address, {
direction: Direction.ANY,
nodePrefix: NodeAddress.empty,
edgePrefix: EdgeAddress.empty,
})
).map((neighbor) => neighbor.node)
)
)}
graph={graph}
pagerankResult={pagerankResult}
depth={depth + 1}
adapters={adapters}
/>
),
];
}
}
type RecursiveTablesProps = {|
+addresses: $ReadOnlyArray<NodeAddressT>,
+graph: Graph,
+pagerankResult: PagerankResult,
+depth: number,
+adapters: $ReadOnlyArray<PluginAdapter>,
|};
class RecursiveTables extends React.Component<RecursiveTablesProps> {
render() {
const {addresses, graph, pagerankResult, depth, adapters} = this.props;
return addresses
.slice()
.sort((a, b) => {
const x = pagerankResult.get(a);
const y = pagerankResult.get(b);
if (x == null) {
throw new Error(`No pagerank result for ${NodeAddress.toString(a)}`);
}
if (y == null) {
throw new Error(`No pagerank result for ${NodeAddress.toString(b)}`);
}
return y - x;
})
.slice(0, MAX_TABLE_ENTRIES)
.map((address) => (
<RecursiveTable
depth={depth}
address={address}
graph={graph}
pagerankResult={pagerankResult}
key={address}
adapters={adapters}
/>
));
}
}

View File

@ -0,0 +1,326 @@
// @flow
import React from "react";
import {mount, shallow} from "enzyme";
import enzymeToJSON from "enzyme-to-json";
import {PagerankTable, nodeDescription} from "./PagerankTable";
import {pagerank} from "../../core/attribution/pagerank";
import sortBy from "lodash.sortby";
import {
Graph,
type NodeAddressT,
NodeAddress,
EdgeAddress,
} from "../../core/graph";
require("../testUtil").configureEnzyme();
function example() {
const graph = new Graph();
const nodes = {
fooAlpha: NodeAddress.fromParts(["foo", "a", "1"]),
fooBeta: NodeAddress.fromParts(["foo", "b", "2"]),
bar1: NodeAddress.fromParts(["bar", "a", "1"]),
bar2: NodeAddress.fromParts(["bar", "2"]),
xox: NodeAddress.fromParts(["xox"]),
empty: NodeAddress.empty,
};
Object.values(nodes).forEach((n) => graph.addNode((n: any)));
function addEdge(parts, src, dst) {
const edge = {address: EdgeAddress.fromParts(parts), src, dst};
graph.addEdge(edge);
}
addEdge(["a"], nodes.fooAlpha, nodes.fooBeta);
addEdge(["b"], nodes.fooAlpha, nodes.bar1);
addEdge(["c"], nodes.fooAlpha, nodes.xox);
addEdge(["d"], nodes.bar1, nodes.bar1);
addEdge(["e"], nodes.bar1, nodes.xox);
addEdge(["e'"], nodes.bar1, nodes.xox);
const adapters = [
{
name: () => "foo",
graph: () => {
throw new Error("unused");
},
renderer: () => ({
nodeDescription: (x) => `foo: ${NodeAddress.toString(x)}`,
}),
nodePrefix: () => NodeAddress.fromParts(["foo"]),
nodeTypes: () => [
{name: "alpha", prefix: NodeAddress.fromParts(["foo", "a"])},
{name: "beta", prefix: NodeAddress.fromParts(["foo", "b"])},
],
},
{
name: () => "bar",
graph: () => {
throw new Error("unused");
},
renderer: () => ({
nodeDescription: (x) => `bar: ${NodeAddress.toString(x)}`,
}),
nodePrefix: () => NodeAddress.fromParts(["bar"]),
nodeTypes: () => [
{name: "alpha", prefix: NodeAddress.fromParts(["bar", "a"])},
],
},
{
name: () => "xox",
graph: () => {
throw new Error("unused");
},
renderer: () => ({
nodeDescription: (_unused_arg) => `xox node!`,
}),
nodePrefix: () => NodeAddress.fromParts(["xox"]),
nodeTypes: () => [],
},
{
name: () => "unused",
graph: () => {
throw new Error("unused");
},
renderer: () => {
throw new Error("Impossible!");
},
nodePrefix: () => NodeAddress.fromParts(["unused"]),
nodeTypes: () => [],
},
];
const pagerankResult = pagerank(graph, (_unused_Edge) => ({
toWeight: 1,
froWeight: 1,
}));
return {adapters, nodes, graph, pagerankResult};
}
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();
// $ExpectFlowError
console.warn = jest.fn();
});
afterEach(() => {
expect(console.warn).not.toHaveBeenCalled();
expect(console.error).not.toHaveBeenCalled();
});
describe("rendering with incomplete props", () => {
it("renders expected message with null props", () => {
const element = shallow(
<PagerankTable pagerankResult={null} graph={null} adapters={null} />
);
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", () => {
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}
/>
);
expect(enzymeToJSON(element)).toMatchSnapshot();
});
});
describe("full rendering", () => {
function exampleRender() {
const {nodes, adapters, graph, pagerankResult} = example();
const element = mount(
<PagerankTable
pagerankResult={pagerankResult}
graph={graph}
adapters={adapters}
/>
);
verifyNoAdapterWarning();
const select = element.find("select");
expect(select).toHaveLength(1);
return {nodes, adapters, graph, pagerankResult, element, select};
}
it("full render doesn't crash or error", () => {
example();
});
describe("tables ", () => {
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("address")));
expect(scores).toEqual(sortBy(scores).reverse());
});
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))
);
const expected = tables.map((x) =>
addressToExpected(x.prop("address"))
);
expect(actual).toEqual(expected);
}
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);
}
);
});
it("subtables 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("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("RecursiveTables")).toHaveLength(0);
button.simulate("click");
expect(button.text()).toEqual("\u2212");
expect(rt().find("RecursiveTables")).toHaveLength(1);
button.simulate("click");
expect(button.text()).toEqual("+");
expect(rt().find("RecursiveTables")).toHaveLength(0);
});
});
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("address")).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("address")).toEqual(example().nodes.fooAlpha);
});
});
});
});

View File

@ -0,0 +1,98 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app/credExplorer/PagerankTable full rendering filter selector has the correct options 1`] = `
Array [
Object {
"style": undefined,
"text": "Show all",
"valueString": "NodeAddress[]",
},
Object {
"style": Object {
"fontWeight": "bold",
},
"text": "bar",
"valueString": "NodeAddress[\\"bar\\"]",
},
Object {
"style": undefined,
"text": "alpha",
"valueString": "NodeAddress[\\"bar\\",\\"a\\"]",
},
Object {
"style": Object {
"fontWeight": "bold",
},
"text": "foo",
"valueString": "NodeAddress[\\"foo\\"]",
},
Object {
"style": undefined,
"text": "alpha",
"valueString": "NodeAddress[\\"foo\\",\\"a\\"]",
},
Object {
"style": undefined,
"text": "beta",
"valueString": "NodeAddress[\\"foo\\",\\"b\\"]",
},
Object {
"style": Object {
"fontWeight": "bold",
},
"text": "unused",
"valueString": "NodeAddress[\\"unused\\"]",
},
Object {
"style": Object {
"fontWeight": "bold",
},
"text": "xox",
"valueString": "NodeAddress[\\"xox\\"]",
},
]
`;
exports[`app/credExplorer/PagerankTable full rendering tables subtables 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`] = `
<p>
Please run PageRank to see analysis.
</p>
`;
exports[`app/credExplorer/PagerankTable rendering with incomplete props renders expected message with null props 1`] = `
<p>
You must load a graph before seeing PageRank analysis.
</p>
`;

18
src/v3/app/testUtil.js Normal file
View File

@ -0,0 +1,18 @@
// @flow
export function configureEnzyme() {
const Enzyme = require("enzyme");
const Adapter = require("enzyme-adapter-react-16");
Enzyme.configure({adapter: new Adapter()});
}
export function configureAphrodite() {
const {StyleSheetTestUtils} = require("aphrodite/no-important");
beforeEach(() => {
StyleSheetTestUtils.suppressStyleInjection();
});
afterEach(() => {
StyleSheetTestUtils.clearBufferAndResumeStyleInjection();
});
}

View File

@ -10,6 +10,7 @@ import {
import {findStationaryDistribution} from "./markovChain"; import {findStationaryDistribution} from "./markovChain";
export type {PagerankResult} from "./graphToMarkovChain";
export type PagerankOptions = {| export type PagerankOptions = {|
+selfLoopWeight?: number, +selfLoopWeight?: number,
+verbose?: boolean, +verbose?: boolean,