Skip to content

Commit a6d3f95

Browse files
committed
Prune graph while ignoring cycles
Highlight cycles in visualization Also write unit tests from algorithms
1 parent 3eff93e commit a6d3f95

File tree

12 files changed

+255
-68
lines changed

12 files changed

+255
-68
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"@types/d3-zoom": "^3.0.1",
1616
"@types/elementary-circuits-directed-graph": "^1.2.0",
1717
"@types/graphlib": "^2.1.8",
18+
"@types/lodash": "^4.14.175",
1819
"@types/node": "^16.10.3",
1920
"@types/numeric": "^1.2.2",
2021
"@types/react": "^17.0.27",
@@ -32,6 +33,7 @@
3233
"elementary-circuits-directed-graph": "^1.3.1",
3334
"eslint-plugin-react-hooks": "^4.2.0",
3435
"graphlib": "^2.1.8",
36+
"lodash": "^4.17.21",
3537
"node-sass": "^6.0.1",
3638
"numeric": "^1.2.6",
3739
"rdf-namespaces": "^1.9.2",

src/App.test.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
/* @TODO */
1+
/* @TODO
22
33
import React from 'react'
44
import { render } from '@testing-library/react'
55
import { Provider } from 'react-redux'
66
import { store } from './app/store'
77
import App from './App'
88
9-
test('renders learn react link', () => {
9+
test.skip('renders learn react link', () => {
1010
const { getByText } = render(
1111
<Provider store={store}>
1212
<App />
@@ -15,3 +15,5 @@ test('renders learn react link', () => {
1515
1616
expect(getByText(/learn/i)).toBeInTheDocument()
1717
})
18+
*/
19+
test.skip('App', () => {})

src/features/login/loginSlice.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
* npx create-react-app --template redux-typescript
33
* so we need to fix it to work with loginSlice
44
*/
5-
5+
test.skip('loginSlice', () => {})
6+
/*
67
import counterReducer, {
78
CounterState,
89
increment,
@@ -37,3 +38,4 @@ describe('counter reducer', () => {
3738
expect(actual.value).toEqual(5)
3839
})
3940
})
41+
*/
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { Dictionary } from '../../types'
2+
import { getCycles, prune, pruneWithCycles } from './algorithms'
3+
import { Definition, Graph, GraphNode, MathDocument, Statement } from './types'
4+
5+
const doc: MathDocument = {
6+
id: '',
7+
uri: '',
8+
access: {
9+
user: { read: true, write: false, append: true },
10+
},
11+
}
12+
13+
const limitGraph = (graph: Graph): Dictionary<Definition | Statement> => {
14+
return Object.fromEntries(
15+
Object.entries(graph).map(([i, o]) => [
16+
i,
17+
{
18+
...o,
19+
id: o.uri,
20+
dependencies: Object.keys(o.dependencies).sort(),
21+
dependents: Object.keys(o.dependents).sort(),
22+
document: o.document.id,
23+
},
24+
]),
25+
)
26+
}
27+
28+
const node: GraphNode = {
29+
uri: '',
30+
type: 'definition',
31+
label: { en: '' },
32+
description: { en: '' },
33+
dependents: {},
34+
dependencies: {},
35+
document: doc,
36+
examples: [],
37+
created: 0,
38+
updated: 0,
39+
}
40+
41+
const makeTestGraph = (matrix: number[][]): Graph => {
42+
const graph: Graph = Object.fromEntries(
43+
matrix.map((el, i) => [
44+
i,
45+
{
46+
...node,
47+
uri: String(i),
48+
dependencies: {} as Dictionary<GraphNode>,
49+
dependents: {} as Dictionary<GraphNode>,
50+
},
51+
]),
52+
)
53+
matrix.forEach((el, i) =>
54+
el.forEach(link => {
55+
graph[i].dependencies[link] = graph[link]
56+
graph[link].dependents[i] = graph[i]
57+
}),
58+
)
59+
60+
return graph
61+
}
62+
63+
const dag = makeTestGraph([[1, 2, 3, 4], [2], [4], [4], []])
64+
const prunedDag = makeTestGraph([[1, 3], [2], [4], [4], []])
65+
const cycle = makeTestGraph([[1, 3], [2, 3, 4], [0], [4], []])
66+
const prunedCycle = makeTestGraph([[1, 3], [2, 3], [0], [4], []])
67+
const cycles = makeTestGraph([[1, 3], [2], [0], [2, 4], []])
68+
const loops = makeTestGraph([[0, 1], [1]])
69+
const midCycle = makeTestGraph([[1], [2, 3, 4], [0, 5, 6], [4], [], [6], []])
70+
const prunedMidCycle = makeTestGraph([[1], [2, 3], [0, 5], [4], [], [6], []])
71+
72+
describe('algorithms', () => {
73+
describe('prune', () => {
74+
it('should prune a DAG', () => {
75+
const pruned = prune(dag)
76+
expect(limitGraph(pruned)).toEqual(limitGraph(prunedDag))
77+
//expect(pruned).toEqual(prunedDag)
78+
expect(true).toEqual(true)
79+
})
80+
it('should fail when graph contains cycle', () => {
81+
expect(() => prune(cycle)).toThrow('pruning is possible on DAG only')
82+
})
83+
})
84+
85+
describe('getCycles', () => {
86+
it('should detect all cycles', () => {
87+
expect(getCycles(cycle)).toEqual([['0', '1', '2']])
88+
expect(getCycles(cycles)).toEqual([
89+
['0', '1', '2'],
90+
['0', '3', '2'],
91+
])
92+
expect(getCycles(dag)).toEqual([])
93+
})
94+
95+
it('should detect loops', () => {
96+
expect(getCycles(loops)).toEqual([['0'], ['1']])
97+
})
98+
})
99+
100+
describe('pruneWithCycles', () => {
101+
it('should prune a directed graph (remove cycles and prune DAG) and return cycles', () => {
102+
const [pruned, foundCycles] = pruneWithCycles(cycle)
103+
expect(limitGraph(pruned)).toEqual(limitGraph(prunedCycle))
104+
expect(foundCycles).toEqual([['0', '1', '2']])
105+
})
106+
107+
it('should prune DAG alone and return no cycles', () => {
108+
const [pruned, foundCycles] = pruneWithCycles(dag)
109+
expect(limitGraph(pruned)).toEqual(limitGraph(prunedDag))
110+
expect(foundCycles).toEqual([])
111+
})
112+
113+
it('should prune another directed graph and return cycles', () => {
114+
const [pruned, foundCycles] = pruneWithCycles(midCycle)
115+
expect(limitGraph(pruned)).toEqual(limitGraph(prunedMidCycle))
116+
expect(foundCycles).toEqual([['0', '1', '2']])
117+
})
118+
})
119+
})

src/features/math/algorithms.ts

Lines changed: 95 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
1-
import graphlib, { Graph } from 'graphlib'
2-
// import { Dependency } from './simulation/types'
3-
// import findCyclesAdjacency from 'elementary-circuits-directed-graph'
4-
import { Graph as AbstractGraph } from './types'
1+
import graphlib from 'graphlib'
2+
import findCircuits from 'elementary-circuits-directed-graph'
3+
import { Graph } from './types'
4+
import cloneDeep from 'lodash.clonedeep'
55

6-
function pruneCore(graph: Graph) {
6+
function pruneCore(graph: graphlib.Graph) {
77
if (!graphlib.alg.isAcyclic(graph)) {
8-
// const cycles = graphlib.alg.findCycles(graph);
98
throw new Error('pruning is possible on DAG only')
109
}
1110
const edges = graph.edges()
@@ -22,7 +21,7 @@ function pruneCore(graph: Graph) {
2221
return graph
2322
}
2423

25-
export const prune = (input: AbstractGraph): AbstractGraph => {
24+
export const prune = (input: Graph): Graph => {
2625
const graph = new graphlib.Graph()
2726

2827
Object.values(input).forEach(node => {
@@ -33,10 +32,10 @@ export const prune = (input: AbstractGraph): AbstractGraph => {
3332
)
3433
})
3534

36-
const output: AbstractGraph = Object.fromEntries(
35+
const output: Graph = Object.fromEntries(
3736
Object.entries(input).map(([uri, node]) => [
3837
uri,
39-
{ ...node, dependencies: {} },
38+
{ ...node, dependencies: {}, dependents: {} },
4039
]),
4140
)
4241

@@ -46,69 +45,111 @@ export const prune = (input: AbstractGraph): AbstractGraph => {
4645

4746
prunedEdges.forEach(({ source, target }) => {
4847
output[source].dependencies[target] = output[target]
48+
output[target].dependents[source] = output[source]
4949
})
5050

5151
return output
5252
}
5353

54-
/*
55-
export function getCycles(dependencies: Dependency[]): Dependency[][] {
56-
const nodes = Array.from(
57-
new Set([
58-
...dependencies.map(d => d.dependent),
59-
...dependencies.map(d => d.dependency),
60-
]),
61-
)
62-
const nodeIndexes = Object.fromEntries(
63-
Object.entries(nodes).map(([key, value]) => [value, +key]),
64-
)
54+
// convert Graph to adjacency list, so we can feed it into the library
55+
// elementary-circuits-directed-graph
56+
const graph2adjacency = (graph: Graph): [AdjacencyList, string[]] => {
57+
const indexes = Object.keys(graph).sort()
6558

66-
const adjacency = Array(nodes.length)
59+
const adjacencyList = Array(indexes.length)
6760
.fill(null)
6861
.map(() => [] as number[])
69-
dependencies.forEach(({ dependent, dependency }) => {
70-
const dependentIndex = nodeIndexes[dependent]
71-
const dependencyIndex = nodeIndexes[dependency]
72-
adjacency[dependentIndex].push(dependencyIndex)
62+
63+
indexes.forEach((id, i) => {
64+
adjacencyList[i] = Object.keys(graph[id].dependencies).map(id2 =>
65+
indexes.findIndex(a => a === id2),
66+
)
7367
})
7468

69+
return [adjacencyList, indexes]
70+
}
71+
72+
type AdjacencyList = number[][]
73+
type Cycle = number[]
74+
type UriCycle = string[]
75+
76+
const findLoops = (adjacencyList: AdjacencyList): Cycle[] => {
77+
return (
78+
adjacencyList
79+
// save the index along with value
80+
.map((a, i) => [a, i] as [number[], number])
81+
// filter every adjacencyList item with loop
82+
.filter(([a, i]) => a.includes(i))
83+
// return simple loop
84+
.map(([, i]) => [i])
85+
)
86+
}
87+
88+
export const getCycles = (graph: Graph): UriCycle[] => {
89+
// first convert graph into a simple adjacency matrix
90+
const [adjacencyList, indexes] = graph2adjacency(graph)
91+
7592
// try to detect loops (cycles of length 1)
7693
// this is due to the limits of the findCyclesAdjacency, which fails to detect loops
7794
// https://github.com/antoinerg/elementary-circuits-directed-graph/issues/13
7895
// @TODO remove loop detection when the issue is fixed
79-
const loops = adjacency.reduce((loops, adj, i) => {
80-
if (adj.includes(i)) loops.push([i, i])
81-
return loops
82-
}, [] as number[][])
96+
const loops = findLoops(adjacencyList)
97+
// get all the other loops
98+
const otherCycles = findCircuits(adjacencyList)
99+
// filter out loops, so we avoid duplicates (see the above-linked issue)
100+
.filter(a => a.length > 2)
101+
// and remove the last element of each cycle
102+
// because the library spits them out in the form [[0, 1, 0], [0, 1, 2, 4, 3, 0]]
103+
.map(cycle => cycle.slice(0, -1))
83104

84-
const rawCycles = loops.length > 0 ? loops : findCyclesAdjacency(adjacency)
85-
86-
const simpleCycles = rawCycles.map(cycle =>
87-
cycle.slice(0, -1).map(i => nodes[i]),
105+
const cycles = [...loops, ...otherCycles].map(cycle =>
106+
cycle.map(i => indexes[i]),
88107
)
89108

90-
return simpleCycles
91-
.map(cycle => simpleCycleToCycle(cycle, dependencies))
92-
.sort((a, b) => a.length - b.length)
93-
.slice(0, 5)
109+
return cycles
94110
}
95111

96-
function simpleCycleToCycle(
97-
simpleCycle: string[],
98-
dependencies: Dependency[],
99-
): Dependency[] {
100-
return simpleCycle.map((uri, index) => {
101-
const dependent = simpleCycle[index]
102-
const dependency = simpleCycle[(index + 1) % simpleCycle.length]
103-
return (
104-
dependencies.find(
105-
d => d.dependency === dependency && d.dependent === dependent,
106-
) || {
107-
dependent,
108-
dependency,
109-
doc: '',
110-
}
112+
export const pruneWithCycles = (graph: Graph): [Graph, UriCycle[]] => {
113+
// clone the graph
114+
graph = cloneDeep(graph)
115+
// first detect cycles
116+
const cycles = getCycles(graph)
117+
const edges = cycles
118+
// collect edges from all cycles
119+
.map(cycle => cycle2edges(cycle))
120+
.flat()
121+
// and filter out duplicates
122+
.filter(
123+
([a, b], i, edges) =>
124+
edges.findIndex(([c, d]) => a === c && b === d) === i,
111125
)
126+
// then remove all edges that are part of cycles
127+
const dag = removeEdges(graph, edges)
128+
// then prune the remaining graph
129+
const prunedDag = prune(dag)
130+
// then add the cycles back
131+
const prunedGraph = addEdges(prunedDag, edges)
132+
// and return
133+
return [prunedGraph, cycles]
134+
}
135+
136+
type Edge = [string, string]
137+
138+
export const cycle2edges = (cycle: UriCycle): Edge[] =>
139+
cycle.map((uri, i, cycle) => [cycle[i], cycle[(i + 1) % cycle.length]])
140+
141+
const removeEdges = (graph: Graph, edges: Edge[]): Graph => {
142+
edges.forEach(([a, b]) => {
143+
delete graph[a].dependencies[b]
144+
delete graph[b].dependents[a]
112145
})
113-
}
114-
*/
146+
return graph
147+
}
148+
149+
const addEdges = (graph: Graph, edges: Edge[]): Graph => {
150+
edges.forEach(([a, b]) => {
151+
graph[a].dependencies[b] = graph[b]
152+
graph[b].dependents[a] = graph[a]
153+
})
154+
return graph
155+
}

src/features/math/mathSlice.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { AppDispatch, RootState } from '../../app/store'
1010
import { Entity } from '../../types'
1111
import { setTemporaryInfo } from '../info/infoSlice'
12-
import { prune } from './algorithms'
12+
import { prune, pruneWithCycles } from './algorithms'
1313
import * as api from './mathAPI'
1414
import {
1515
Definition,
@@ -264,6 +264,10 @@ export const selectGraph = createSelector(
264264
},
265265
)
266266

267+
export const selectPrunedGraphAndCycles = createSelector(selectGraph, graph =>
268+
pruneWithCycles(graph),
269+
)
270+
267271
export const selectPrunedGraph = createSelector(selectGraph, graph =>
268272
prune(graph),
269273
)

src/features/math/statement/StatementView.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,8 @@ const StatementView = ({ onEdit }: { onEdit: () => void }) => {
6969
const onSelectNode = (uri: string) => dispatch(select(uri))
7070
const node = useAppSelector(selectSelectedNode)
7171

72+
if (node.label.en === 'aa') console.log(node)
73+
7274
const dependencies: GraphNode[] = Object.values(node.dependencies)
7375
const dependents: GraphNode[] = Object.values(node.dependents)
7476

0 commit comments

Comments
 (0)