Configure forward/backward edge weights separately (#749)

This commit introduces a new component, `EdgeTypeConfig`, which is
responsible for configuring the weights for a given edge type. The
config creates two `WeightSlider`s: one for the forward direction, and
one for the backward direction. The `DirectionalitySlider` is no longer
used, and is removed. This fixes #596.

So as to avoid confusion, we now describe every edge with variables, as
in 'α REFERENCES β', and clarify that the weight modifies how cred flows
from β to α. This necessitated the creation of an `EdgeWeightSlider`,
local to the `EdgeTypeConfig`, which sets up a `WeightSlider` with the
necessary greek characters.

The EdgeTypeConfig is tested, so this is continuing progress towards
solving #604.

Test plan: I manually verified that modifying edge weights has the
expected effect on cred scores. Also, some new unit tests are included.
This commit is contained in:
Dandelion Mané 2018-09-04 15:37:00 -07:00 committed by GitHub
parent 4cd45c77fc
commit c7e5a3b87d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 253 additions and 220 deletions

View File

@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
- Configure edge forward/backward weights separately (#749)
- Combine "load graph" and "run pagerank" into one button (#759)
- Store GitHub data compressed at rest, reducing space usage by 68× (#750)
- Improve weight sliders display (#736)

View File

@ -6,35 +6,24 @@ import sortBy from "lodash.sortby";
import {type EdgeEvaluator} from "../../core/attribution/pagerank";
import {byEdgeType, byNodeType} from "./edgeWeights";
import {defaultStaticAdapters} from "../adapters/defaultPlugins";
import type {EdgeType} from "../adapters/pluginAdapter";
import {WeightSlider} from "./weights/WeightSlider";
import {DirectionalitySlider} from "./weights/DirectionalitySlider";
import {
NodeTypeConfig,
defaultWeightedNodeType,
type WeightedNodeType,
} from "./weights/NodeTypeConfig";
import {
EdgeTypeConfig,
defaultWeightedEdgeType,
type WeightedEdgeType,
} from "./weights/EdgeTypeConfig";
import {styledVariable} from "./weights/EdgeTypeConfig";
type Props = {|
+onChange: (EdgeEvaluator) => void,
|};
type WeightedEdgeType = {|
+type: EdgeType,
+weight: number,
+directionality: number,
|};
type EdgeWeights = WeightedEdgeType[];
const defaultEdgeWeights = (): EdgeWeights => {
const result = [];
for (const type of defaultStaticAdapters().edgeTypes()) {
result.push({type, weight: 1.0, directionality: 0.5});
}
return result;
};
type State = {
edgeWeights: EdgeWeights,
edgeWeights: $ReadOnlyArray<WeightedEdgeType>,
nodeWeights: $ReadOnlyArray<WeightedNodeType>,
expanded: boolean,
};
@ -43,7 +32,9 @@ export class WeightConfig extends React.Component<Props, State> {
constructor(props: Props): void {
super(props);
this.state = {
edgeWeights: defaultEdgeWeights(),
edgeWeights: defaultStaticAdapters()
.edgeTypes()
.map(defaultWeightedEdgeType),
nodeWeights: defaultStaticAdapters()
.nodeTypes()
.map(defaultWeightedNodeType),
@ -94,11 +85,13 @@ export class WeightConfig extends React.Component<Props, State> {
fire() {
const {edgeWeights, nodeWeights} = this.state;
const edgePrefixes = edgeWeights.map(({type, weight, directionality}) => ({
const edgePrefixes = edgeWeights.map(
({type, forwardWeight, backwardWeight}) => ({
prefix: type.prefix,
weight,
directionality,
}));
forwardWeight,
backwardWeight,
})
);
const nodePrefixes = nodeWeights.map(({type, weight}) => ({
prefix: type.prefix,
weight,
@ -109,63 +102,38 @@ export class WeightConfig extends React.Component<Props, State> {
}
class EdgeConfig extends React.Component<{
edgeWeights: EdgeWeights,
onChange: (EdgeWeights) => void,
edgeWeights: $ReadOnlyArray<WeightedEdgeType>,
onChange: ($ReadOnlyArray<WeightedEdgeType>) => void,
}> {
weightControls() {
const sortedWeights = sortBy(
this.props.edgeWeights,
({type}) => type.prefix
);
return sortedWeights.map(({type, directionality, weight}) => {
_renderWeightControls() {
return sortBy(this.props.edgeWeights, ({type}) => type.prefix).map(
(weightedEdgeType) => {
const onChange = (value) => {
const edgeWeights = this.props.edgeWeights.filter(
(x) => x.type.prefix !== type.prefix
(x) => x.type.prefix !== weightedEdgeType.type.prefix
);
edgeWeights.push({type, weight: value, directionality});
edgeWeights.push(value);
this.props.onChange(edgeWeights);
};
return (
<WeightSlider
key={type.prefix}
weight={weight}
name={`${type.forwardName} / ${type.backwardName}`}
<EdgeTypeConfig
key={weightedEdgeType.type.prefix}
weightedType={weightedEdgeType}
onChange={onChange}
/>
);
});
}
);
}
directionControls() {
const sortedWeights = sortBy(
this.props.edgeWeights,
({type}) => type.prefix
);
return sortedWeights.map(({type, directionality, weight}) => {
const onChange = (value: number) => {
const edgeWeights = this.props.edgeWeights.filter(
(x) => x.type.prefix !== type.prefix
);
edgeWeights.push({type, directionality: value, weight});
this.props.onChange(edgeWeights);
};
return (
<DirectionalitySlider
name={type.forwardName}
key={type.prefix}
directionality={directionality}
onChange={onChange}
/>
);
});
}
render() {
return (
<div>
<h2>Edge weights</h2>
{this.weightControls()}
<h2>Edge directionality</h2>
{this.directionControls()}
<p>
Flow cred from {styledVariable("β")} to {styledVariable("α")} when:
</p>
{this._renderWeightControls()}
</div>
);
}

View File

@ -10,20 +10,16 @@ import type {EdgeEvaluator} from "../../core/attribution/pagerank";
export function byEdgeType(
prefixes: $ReadOnlyArray<{|
+prefix: EdgeAddressT,
+weight: number,
+directionality: number,
+forwardWeight: number,
+backwardWeight: number,
|}>
): EdgeEvaluator {
const trie = new EdgeTrie();
for (const weightedPrefix of prefixes) {
trie.add(weightedPrefix.prefix, weightedPrefix);
for (const {prefix, forwardWeight, backwardWeight} of prefixes) {
trie.add(prefix, {toWeight: forwardWeight, froWeight: backwardWeight});
}
return function evaluator(edge: Edge) {
const {weight, directionality} = trie.getLast(edge.address);
return {
toWeight: directionality * weight,
froWeight: (1 - directionality) * weight,
};
return trie.getLast(edge.address);
};
}
@ -35,17 +31,17 @@ export function byNodeType(
|}>
): EdgeEvaluator {
const trie = new NodeTrie();
for (const weightedPrefix of prefixes) {
trie.add(weightedPrefix.prefix, weightedPrefix);
for (const {weight, prefix} of prefixes) {
trie.add(prefix, weight);
}
return function evaluator(edge: Edge) {
const srcDatum = trie.getLast(edge.src);
const dstDatum = trie.getLast(edge.dst);
const srcWeight = trie.getLast(edge.src);
const dstWeight = trie.getLast(edge.dst);
const baseResult = base(edge);
return {
toWeight: dstDatum.weight * baseResult.toWeight,
froWeight: srcDatum.weight * baseResult.froWeight,
toWeight: dstWeight * baseResult.toWeight,
froWeight: srcWeight * baseResult.froWeight,
};
};
}

View File

@ -1,39 +0,0 @@
// @flow
import React from "react";
function assertValidDirectionality(x: number) {
if (x < 0 || x > 1) {
throw new Error(
`directionality out of bounds: ${x} must be between 0 and 1`
);
}
}
export class DirectionalitySlider extends React.Component<{|
+directionality: number,
+name: string,
+onChange: (number) => void,
|}> {
render() {
assertValidDirectionality(this.props.directionality);
return (
<label style={{display: "block"}}>
<input
type="range"
min={0}
max={1}
step={0.01}
value={this.props.directionality}
onChange={(e) => {
const value = e.target.valueAsNumber;
assertValidDirectionality(value);
this.props.onChange(value);
}}
/>
<span>{this.props.directionality.toFixed(2)}</span>
<span>{this.props.name}</span>
</label>
);
}
}

View File

@ -1,89 +0,0 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import {DirectionalitySlider} from "./DirectionalitySlider";
require("../../testUtil").configureEnzyme();
describe("app/credExplorer/weights/DirectionalitySlider", () => {
describe("DirectionalitySlider", () => {
function example() {
const onChange = jest.fn();
const element = shallow(
<DirectionalitySlider
directionality={0.5}
name={"foo"}
onChange={onChange}
/>
);
return {element, onChange};
}
it("sets slider to the provided weight", () => {
const {element} = example();
expect(element.find("input").props().value).toBe(0.5);
});
it("slider min is 0", () => {
const {element} = example();
expect(element.find("input").props().min).toBe(0);
});
it("slider max is 0", () => {
const {element} = example();
expect(element.find("input").props().max).toBe(1);
});
it("prints the provided weight", () => {
const {element} = example();
expect(
element
.find("span")
.at(0)
.text()
).toBe("0.50");
});
it("displays the provided name", () => {
const {element} = example();
expect(
element
.find("span")
.at(1)
.text()
).toBe("foo");
});
it("changes to the slider trigger the onChange", () => {
const {element, onChange} = example();
const input = element.find("input");
input.simulate("change", {target: {valueAsNumber: 0.99}});
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange).toHaveBeenCalledWith(0.99);
});
it("errors if provided an out-of-bound directionality", () => {
function withDirectionality(d) {
return () =>
shallow(
<DirectionalitySlider
directionality={d}
name={"foo"}
onChange={jest.fn()}
/>
);
}
expect(withDirectionality(-0.2)).toThrowError(
"directionality out of bounds"
);
expect(withDirectionality(2)).toThrowError(
"directionality out of bounds"
);
});
it("errors rather than providing an out-of-bound directionality", () => {
const {element} = example();
const input = element.find("input");
expect(() => {
input.simulate("change", {target: {valueAsNumber: -0.2}});
}).toThrowError("directionality out of bounds");
expect(() => {
input.simulate("change", {target: {valueAsNumber: 2.0}});
}).toThrowError("directionality out of bounds");
});
});
});

View File

@ -0,0 +1,77 @@
// @flow
import React from "react";
import {WeightSlider, type Props as WeightSliderProps} from "./WeightSlider";
import {type EdgeType} from "../../adapters/pluginAdapter";
export type WeightedEdgeType = {|
+type: EdgeType,
+forwardWeight: number,
+backwardWeight: number,
|};
export function defaultWeightedEdgeType(type: EdgeType): WeightedEdgeType {
return {
type,
forwardWeight: 1,
backwardWeight: 1,
};
}
export class EdgeTypeConfig extends React.Component<{
+weightedType: WeightedEdgeType,
+onChange: (WeightedEdgeType) => void,
}> {
render() {
return (
<div>
<EdgeWeightSlider
name={this.props.weightedType.type.backwardName}
weight={this.props.weightedType.forwardWeight}
onChange={(forwardWeight) => {
this.props.onChange({
...this.props.weightedType,
forwardWeight,
});
}}
/>
<EdgeWeightSlider
name={this.props.weightedType.type.forwardName}
weight={this.props.weightedType.backwardWeight}
onChange={(backwardWeight) => {
this.props.onChange({
...this.props.weightedType,
backwardWeight,
});
}}
/>
</div>
);
}
}
export function styledVariable(letter: string) {
return (
// marginRight accounts for italicization
<span style={{fontWeight: 700, fontStyle: "italic", marginRight: "0.15em"}}>
{letter}
</span>
);
}
export class EdgeWeightSlider extends React.Component<WeightSliderProps> {
render() {
const modifiedName = (
<React.Fragment>
{styledVariable("α")} {this.props.name} {styledVariable("β")}
</React.Fragment>
);
return (
<WeightSlider
name={modifiedName}
weight={this.props.weight}
onChange={this.props.onChange}
/>
);
}
}

View File

@ -0,0 +1,87 @@
// @flow
import React from "react";
import {shallow} from "enzyme";
import {WeightSlider} from "./WeightSlider";
import {
defaultWeightedEdgeType,
EdgeTypeConfig,
EdgeWeightSlider,
} from "./EdgeTypeConfig";
import {assemblesEdgeType} from "../../adapters/demoAdapters";
require("../../testUtil").configureEnzyme();
describe("app/credExplorer/weights/EdgeTypeConfig", () => {
describe("defaultWeightedEdgeType", () => {
it("sets default weights to 1, 1", () => {
const wet = defaultWeightedEdgeType(assemblesEdgeType);
expect(wet.forwardWeight).toEqual(1);
expect(wet.backwardWeight).toEqual(1);
});
});
describe("EdgeTypeConfig", () => {
function example() {
const onChange = jest.fn();
const wet = {
type: assemblesEdgeType,
forwardWeight: 1,
backwardWeight: 0.5,
};
const element = shallow(
<EdgeTypeConfig onChange={onChange} weightedType={wet} />
);
const forwardSlider = element.find(EdgeWeightSlider).at(0);
const backwardSlider = element.find(EdgeWeightSlider).at(1);
return {onChange, wet, forwardSlider, backwardSlider};
}
it("sets up the forward weight slider", () => {
const {wet, forwardSlider} = example();
expect(forwardSlider.props().name).toBe(assemblesEdgeType.backwardName);
expect(forwardSlider.props().weight).toBe(wet.forwardWeight);
});
it("sets up the backward weight slider", () => {
const {wet, backwardSlider} = example();
expect(backwardSlider.props().name).toBe(assemblesEdgeType.forwardName);
expect(backwardSlider.props().weight).toBe(wet.backwardWeight);
});
it("forward weight slider onChange works", () => {
const {wet, forwardSlider, onChange} = example();
forwardSlider.props().onChange(9);
const updated = {...wet, forwardWeight: 9};
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toEqual(updated);
});
it("backward weight slider onChange works", () => {
const {wet, backwardSlider, onChange} = example();
backwardSlider.props().onChange(9);
const updated = {...wet, backwardWeight: 9};
expect(onChange).toHaveBeenCalledTimes(1);
expect(onChange.mock.calls[0][0]).toEqual(updated);
});
});
describe("EdgeWeightSlider", () => {
function example() {
const onChange = jest.fn();
const element = shallow(
<EdgeWeightSlider weight={3} name="foo" onChange={onChange} />
);
const weightSlider = element.find(WeightSlider);
return {element, onChange, weightSlider};
}
it("renders the name along with some Greek characters", () => {
const {weightSlider} = example();
const name = weightSlider.props().name;
expect(name).toMatchSnapshot();
});
it("passes through the weight unchanged", () => {
const {weightSlider} = example();
expect(weightSlider.props().weight).toBe(3);
});
it("onChange is wired properly", () => {
const {weightSlider, onChange} = example();
expect(weightSlider.props().onChange).toBe(onChange);
});
});
});

View File

@ -2,11 +2,12 @@
import React from "react";
export class WeightSlider extends React.Component<{|
export type Props = {|
+weight: number,
+name: string,
+name: React$Node,
+onChange: (number) => void,
|}> {
|};
export class WeightSlider extends React.Component<Props> {
render() {
return (
<label style={{display: "flex"}}>

View File

@ -12,7 +12,7 @@ describe("app/credExplorer/weights/WeightSlider", () => {
function example() {
const onChange = jest.fn();
const element = shallow(
<WeightSlider weight={3} name={"foo"} onChange={onChange} />
<WeightSlider weight={3} name="foo" onChange={onChange} />
);
return {element, onChange};
}

View File

@ -0,0 +1,31 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`app/credExplorer/weights/EdgeTypeConfig EdgeWeightSlider renders the name along with some Greek characters 1`] = `
<React.Fragment>
<span
style={
Object {
"fontStyle": "italic",
"fontWeight": 700,
"marginRight": "0.15em",
}
}
>
α
</span>
foo
<span
style={
Object {
"fontStyle": "italic",
"fontWeight": 700,
"marginRight": "0.15em",
}
}
>
β
</span>
</React.Fragment>
`;