Skip to content

Commit

Permalink
Ensure deterministic graph calculation with consistent layer, node, a…
Browse files Browse the repository at this point in the history
…nd edge ordering in Kedro-Viz (#2185)

Resolves #2057.

Based on @astrojuanlu's comment, I realized that Kedro-Viz might be causing the randomness. To investigate, I checked if the inputs to graph calculations—nodes, edges, and layout—were consistent. While the order of nodes and layout remained stable, the order of edges varied with each backend data request. To address this, we now sort the layers, the edges and nodes to ensure consistency.

Update -
As @ravi-kumar-pilla noted, there was an issue with layer ordering: changing a node name could alter the layer order, especially for layers with identical dependencies, like model_input and feature in the example he shared. This issue stemmed from the layer_dependencies dictionary in services/layers.py, where layers with the same dependencies weren’t consistently ordered. To fix this, I added alphabetical sorting for layers with identical dependencies to ensure stability in toposort.

For nodes and edges, I now sort them immediately upon loading from the backend API, ensuring they are consistently ordered in the Redux state.

For testing, I initially considered using Cypress/backend e2e testing with screenshot comparison of the flowchart, but it proved too complex. Instead, I created a new mock dataset, reordered_spaceflights, with reordered nodes and edges. I added tests in normalise-data-test and actions/graph-test. The first test verifies that nodes and edges are consistently sorted by their ids, regardless of backend order. The second test compares the x and y coordinates in the flowchart, confirming that the graph layout is the same between the two mocks.
  • Loading branch information
rashidakanchwala authored Nov 18, 2024
1 parent 5a7f11a commit e8beb31
Show file tree
Hide file tree
Showing 8 changed files with 434 additions and 19 deletions.
5 changes: 5 additions & 0 deletions package/kedro_viz/services/layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,11 @@ def find_child_layers(node_id: str) -> Set[str]:
if layer not in layer_dependencies:
layer_dependencies[layer] = set()

# Sort `layer_dependencies` keys for consistent ordering of layers with the same dependencies
layer_dependencies = defaultdict(
set, {k: layer_dependencies[k] for k in sorted(layer_dependencies)}
)

# Use graphlib.TopologicalSorter to sort the layer dependencies.
try:
sorter = TopologicalSorter(layer_dependencies)
Expand Down
28 changes: 27 additions & 1 deletion package/tests/test_services/test_layers.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,32 @@
{"node_1": {}, "node_2": {}},
["a", "b"],
),
(
# Case where if two layers e.g. `int` and `primary` layers share the same dependencies, they get sorted alphabetically.
"""
node_1(layer=raw) -> node_3(layer=int)
node_2(layer=raw) -> node_4(layer=primary)
node_3(layer=int) -> node_5(layer=feature)
node_4(layer=primary) -> node_6(layer=feature)
""",
{
"node_1": {"id": "node_1", "layer": "raw"},
"node_2": {"id": "node_2", "layer": "raw"},
"node_3": {"id": "node_3", "layer": "int"},
"node_4": {"id": "node_4", "layer": "primary"},
"node_5": {"id": "node_5", "layer": "feature"},
"node_6": {"id": "node_6", "layer": "feature"},
},
{
"node_1": {"node_3"},
"node_2": {"node_4"},
"node_3": {"node_5"},
"node_4": {"node_6"},
"node_5": set(),
"node_6": set(),
},
["raw", "int", "primary", "feature"],
),
],
)
def test_sort_layers(graph_schema, nodes, node_dependencies, expected):
Expand All @@ -170,7 +196,7 @@ def test_sort_layers(graph_schema, nodes, node_dependencies, expected):
for node_id, node_dict in nodes.items()
}
sorted_layers = sort_layers(nodes, node_dependencies)
assert sorted(sorted_layers) == sorted(expected), graph_schema
assert sorted_layers == expected, graph_schema


def test_sort_layers_should_return_empty_list_on_cyclic_layers(mocker):
Expand Down
78 changes: 61 additions & 17 deletions src/actions/graph.test.js
Original file line number Diff line number Diff line change
@@ -1,25 +1,37 @@
import { createStore } from 'redux';
import reducer from '../reducers';
import { mockState } from '../utils/state.mock';
import { calculateGraph, updateGraph } from './graph';
import { getGraphInput } from '../selectors/layout';
import { prepareState } from '../utils/state.mock';
import spaceflights from '../utils/data/spaceflights.mock.json';
import spaceflightsReordered from '../utils/data/spaceflights_reordered.mock.json';
import { toggleModularPipelinesExpanded } from '../actions/modular-pipelines';

describe('graph actions', () => {
const getMockState = (data) =>
prepareState({
data,
beforeLayoutActions: [
() =>
toggleModularPipelinesExpanded(['data_science', 'data_processing']),
],
});

describe('calculateGraph', () => {
it('returns updateGraph action if input is falsey', () => {
expect(calculateGraph(null)).toEqual(updateGraph(null));
});

it('sets loading to true immediately', () => {
const store = createStore(reducer, mockState.spaceflights);
const store = createStore(reducer, getMockState(spaceflights));
expect(store.getState().loading.graph).not.toBe(true);
calculateGraph(getGraphInput(mockState.spaceflights))(store.dispatch);
calculateGraph(getGraphInput(getMockState(spaceflights)))(store.dispatch);
expect(store.getState().loading.graph).toBe(true);
});

it('sets loading to false and graph visibility to true after finishing calculation', () => {
const store = createStore(reducer, mockState.spaceflights);
return calculateGraph(getGraphInput(mockState.spaceflights))(
const store = createStore(reducer, getMockState(spaceflights));
return calculateGraph(getGraphInput(getMockState(spaceflights)))(
store.dispatch
).then(() => {
const state = store.getState();
Expand All @@ -29,19 +41,51 @@ describe('graph actions', () => {
});

it('calculates a graph', () => {
const state = Object.assign({}, mockState.spaceflights);
delete state.graph;
const store = createStore(reducer, state);
const initialState = { ...getMockState(spaceflights), graph: {} };
const store = createStore(reducer, initialState);
expect(store.getState().graph).toEqual({});
return calculateGraph(getGraphInput(state))(store.dispatch).then(() => {
expect(store.getState().graph).toEqual(
expect.objectContaining({
nodes: expect.any(Array),
edges: expect.any(Array),
size: expect.any(Object),
})
);
});
return calculateGraph(getGraphInput(initialState))(store.dispatch).then(
() => {
expect(store.getState().graph).toEqual(
expect.objectContaining({
nodes: expect.any(Array),
edges: expect.any(Array),
size: expect.any(Object),
})
);
}
);
});

it('compares deterministic flowchart of two differently ordered same projects', () => {
const store1 = createStore(reducer, getMockState(spaceflights));
const store2 = createStore(reducer, getMockState(spaceflightsReordered));

return calculateGraph(getGraphInput(getMockState(spaceflights)))(
store1.dispatch
)
.then(() =>
calculateGraph(getGraphInput(getMockState(spaceflightsReordered)))(
store2.dispatch
)
)
.then(() => {
// Get node coordinates for both graphs
const graph1Coords = store1.getState().graph.nodes.map((node) => ({
id: node.id,
x: node.x,
y: node.y,
}));

const graph2Coords = store2.getState().graph.nodes.map((node) => ({
id: node.id,
x: node.x,
y: node.y,
}));

// Verify coordinates consistency between both graphs
expect(graph1Coords).toEqual(expect.arrayContaining(graph2Coords));
});
});
});
});
2 changes: 1 addition & 1 deletion src/selectors/sliced-pipeline.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ describe('Selectors', () => {
const expected = [
'23c94afb',
'47b81aa6',
'90ebe5f3',
'daf35ba0',
'c09084f2',
'0abef172',
'e5a9ec27',
'b7bb7198',
'f192326a',
'90ebe5f3',
'data_processing',
];
const newState = reducer(mockState.spaceflights, {
Expand Down
10 changes: 10 additions & 0 deletions src/store/normalize-data.js
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,15 @@ const getNodeTypesFromUrl = (state, typeQueryParams) => {
return state;
};

/**
* Sort the edges, nodes in the state object to ensure deterministic graph layout
* @param {Object} state The state object to sort
*/
const sortNodesEdges = (state) => {
state.edge?.ids?.sort((a, b) => a.localeCompare(b));
state.node?.ids?.sort((a, b) => a.localeCompare(b));
};

/**
* Updates the state with filters from the URL.
* @param {Object} state - State object
Expand Down Expand Up @@ -331,6 +340,7 @@ const normalizeData = (data, expandAllPipelines) => {
data.layers.forEach(addLayer(state));
}

sortNodesEdges(state);
const updatedState = updateStateWithFilters(state, data.tags);
return updatedState;
};
Expand Down
17 changes: 17 additions & 0 deletions src/store/normalize-data.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import normalizeData, { createInitialPipelineState } from './normalize-data';
import spaceflights from '../utils/data/spaceflights.mock.json';
import spaceflightsReordered from '../utils/data/spaceflights_reordered.mock.json';

const initialState = createInitialPipelineState();

Expand Down Expand Up @@ -90,4 +91,20 @@ describe('normalizeData', () => {
expect(node).toHaveProperty('name');
});
});

it('should have identical nodes and edges, in the same order, regardless of the different ordering from the api', () => {
// Normalize both datasets
const initialState = normalizeData(spaceflights, true);
const reorderedState = normalizeData(spaceflightsReordered, true);

// Compare nodes and edges by converting to JSON for deep equality
// Directly compare specific properties of nodes and edges, ensuring order and content
expect(initialState.node.ids).toEqual(reorderedState.node.ids);
expect(initialState.node.name).toEqual(reorderedState.node.name);
expect(initialState.node.type).toEqual(reorderedState.node.type);

expect(initialState.edge.ids).toEqual(reorderedState.edge.ids);
expect(initialState.edge.sources).toEqual(reorderedState.edge.sources);
expect(initialState.edge.targets).toEqual(reorderedState.edge.targets);
});
});
Loading

0 comments on commit e8beb31

Please sign in to comment.