Skip to content

Commit 6af5f61

Browse files
committed
feat: LCS-based movement implementation
This new algorithm implements generation of move actions by utilizing a longest common subsequence algorithm (LCS). First, based on the position type, we generate a list describing the expected order of the elements. LCS algorithm is then used to find the longest sequence of items that is shared between the expected list, and an actual list (e.g. a list of entries from the server). Once longest sequence is known, we figure out the least amount of moves to translate existing list into its expected form, and those movements are returned back.
1 parent 11eb059 commit 6af5f61

File tree

3 files changed

+500
-0
lines changed

3 files changed

+500
-0
lines changed

assets/pango/movement/movement.go

Lines changed: 348 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,348 @@
1+
package movement
2+
3+
import (
4+
"fmt"
5+
"log/slog"
6+
"slices"
7+
)
8+
9+
var _ = slog.LevelDebug
10+
11+
type Movable interface {
12+
EntryName() string
13+
}
14+
15+
type MoveAction struct {
16+
EntryName string
17+
Where string
18+
Destination string
19+
}
20+
21+
type Position interface {
22+
Move(entries []Movable, existing []Movable) ([]MoveAction, error)
23+
}
24+
25+
type PositionTop struct{}
26+
27+
type PositionBottom struct{}
28+
29+
type PositionBefore struct {
30+
Directly bool
31+
Pivot Movable
32+
}
33+
34+
type PositionAfter struct {
35+
Directly bool
36+
Pivot Movable
37+
}
38+
39+
func removeEntriesFromExisting(existing []Movable, entries []Movable) []Movable {
40+
entryNames := make(map[string]bool, len(entries))
41+
for _, elt := range entries {
42+
entryNames[elt.EntryName()] = true
43+
}
44+
45+
filtered := make([]Movable, len(existing))
46+
copy(filtered, existing)
47+
48+
filtered = slices.DeleteFunc(filtered, func(entry Movable) bool {
49+
_, ok := entryNames[entry.EntryName()]
50+
return ok
51+
})
52+
53+
return filtered
54+
}
55+
56+
func findPivotIdx(entries []Movable, pivot Movable) int {
57+
return slices.IndexFunc(entries, func(entry Movable) bool {
58+
if entry.EntryName() == pivot.EntryName() {
59+
return true
60+
}
61+
62+
return false
63+
})
64+
65+
}
66+
67+
type movementType int
68+
69+
const (
70+
movementBefore movementType = iota
71+
movementAfter
72+
)
73+
74+
func processPivotMovement(entries []Movable, existing []Movable, pivot Movable, direct bool, movement movementType) ([]MoveAction, error) {
75+
existingLen := len(existing)
76+
existingIdxMap := make(map[Movable]int, existingLen)
77+
78+
for idx, elt := range existing {
79+
existingIdxMap[elt] = idx
80+
}
81+
82+
pivotIdx := findPivotIdx(existing, pivot)
83+
if pivotIdx == -1 {
84+
return nil, fmt.Errorf("pivot point not found in the list of existing items")
85+
}
86+
87+
if !direct {
88+
movementRequired := false
89+
entriesLen := len(entries)
90+
loop:
91+
for i := 0; i < entriesLen; i++ {
92+
93+
// For any given entry in the list of entries to move check if the entry
94+
// index is at or after pivot point index, which will require movement
95+
// set to be generated.
96+
existingEntryIdx := existingIdxMap[entries[i]]
97+
switch movement {
98+
case movementBefore:
99+
if existingEntryIdx >= pivotIdx {
100+
movementRequired = true
101+
break
102+
}
103+
case movementAfter:
104+
if existingEntryIdx <= pivotIdx {
105+
movementRequired = true
106+
break
107+
}
108+
}
109+
110+
if i == 0 {
111+
continue
112+
}
113+
114+
// Check if the entries to be moved have the same order in the existing
115+
// slice, and if not require a movement set to be generated.
116+
switch movement {
117+
case movementBefore:
118+
if existingIdxMap[entries[i-1]] >= existingEntryIdx {
119+
movementRequired = true
120+
break loop
121+
122+
}
123+
case movementAfter:
124+
if existingIdxMap[entries[i-1]] <= existingEntryIdx {
125+
movementRequired = true
126+
break loop
127+
128+
}
129+
130+
}
131+
}
132+
133+
if !movementRequired {
134+
return nil, nil
135+
}
136+
}
137+
138+
expected := make([]Movable, len(existing))
139+
140+
filtered := removeEntriesFromExisting(existing, entries)
141+
filteredPivotIdx := findPivotIdx(filtered, pivot)
142+
143+
switch movement {
144+
case movementBefore:
145+
expectedIdx := 0
146+
for ; expectedIdx < filteredPivotIdx; expectedIdx++ {
147+
expected[expectedIdx] = filtered[expectedIdx]
148+
}
149+
150+
for _, elt := range entries {
151+
expected[expectedIdx] = elt
152+
expectedIdx++
153+
}
154+
155+
expected[expectedIdx] = pivot
156+
expectedIdx++
157+
158+
filteredLen := len(filtered)
159+
for i := filteredPivotIdx + 1; i < filteredLen; i++ {
160+
expected[expectedIdx] = filtered[i]
161+
expectedIdx++
162+
}
163+
}
164+
165+
return GenerateMovements(existing, expected)
166+
}
167+
168+
func (o PositionAfter) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
169+
return processPivotMovement(entries, existing, o.Pivot, o.Directly, movementAfter)
170+
}
171+
172+
func (o PositionBefore) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
173+
return processPivotMovement(entries, existing, o.Pivot, o.Directly, movementBefore)
174+
}
175+
176+
type Entry struct {
177+
Element Movable
178+
Expected int
179+
Existing int
180+
}
181+
182+
type sequencePosition struct {
183+
Start int
184+
End int
185+
}
186+
187+
func longestCommonSubsequence(existing []Movable, expected []Movable) ([]Movable, sequencePosition) {
188+
// Implementation of DP-based algorithm for solving LCP
189+
// See https://en.wikipedia.org/wiki/Longest_common_subsequence for details
190+
// and the algorithm.
191+
m, n := len(existing), len(expected)
192+
193+
dp := make([][]int, m+1)
194+
for i := range m + 1 {
195+
dp[i] = make([]int, n+1)
196+
}
197+
198+
for i := 0; i < m; i++ {
199+
for j := 0; j < n; j++ {
200+
if existing[i].EntryName() == expected[j].EntryName() {
201+
dp[i+1][j+1] = 1 + dp[i][j]
202+
} else {
203+
dp[i+1][j+1] = max(dp[i+1][j], dp[i][j+1])
204+
}
205+
}
206+
}
207+
208+
// Once LCS algorithm is finished we know the length of the longest common
209+
// sequence (dp[m][n]) and we can then go backward in the matrix to build
210+
// the actual sequence.
211+
index := dp[m][n]
212+
213+
i := m
214+
j := n
215+
216+
commonSubsequence := make([]Movable, index)
217+
218+
position := sequencePosition{
219+
Start: i - index,
220+
End: i - 1,
221+
}
222+
223+
for {
224+
if i == 0 || j == 0 {
225+
break
226+
}
227+
228+
if existing[i-1].EntryName() == expected[j-1].EntryName() {
229+
commonSubsequence[index-1] = existing[i-1]
230+
231+
i--
232+
j--
233+
index--
234+
235+
} else if dp[i-1][j] > dp[i][j-1] {
236+
i--
237+
} else {
238+
j--
239+
}
240+
}
241+
242+
return commonSubsequence, position
243+
}
244+
245+
func GenerateMovements(existing []Movable, expected []Movable) ([]MoveAction, error) {
246+
if len(existing) != len(expected) {
247+
return nil, fmt.Errorf("existing length != expected length: %d != %d", len(existing), len(expected))
248+
}
249+
250+
commonSequence, position := longestCommonSubsequence(existing, expected)
251+
252+
existingIdxMap := make(map[Movable]int, len(existing))
253+
for idx, elt := range existing {
254+
existingIdxMap[elt] = idx
255+
}
256+
257+
expectedIdxMap := make(map[Movable]int, len(expected))
258+
for idx, elt := range expected {
259+
expectedIdxMap[elt] = idx
260+
}
261+
262+
commonIdxMap := make(map[Movable]int, len(commonSequence))
263+
for idx, elt := range commonSequence {
264+
commonIdxMap[elt] = idx
265+
}
266+
267+
var movements []MoveAction
268+
269+
expectedCommonLastIdx := expectedIdxMap[existing[position.End]]
270+
271+
firstElt := existing[0]
272+
afterElt := existing[position.End]
273+
for _, elt := range existing {
274+
var ok bool
275+
if _, ok = commonIdxMap[elt]; ok {
276+
continue
277+
}
278+
279+
existingIdx, _ := existingIdxMap[elt]
280+
expectedIdx, _ := expectedIdxMap[elt]
281+
282+
if expectedIdx == existingIdx {
283+
continue
284+
} else if expectedIdx < expectedCommonLastIdx && expectedIdx == 0 {
285+
movements = append(movements, MoveAction{
286+
EntryName: elt.EntryName(),
287+
Where: "top",
288+
Destination: "top",
289+
})
290+
firstElt = elt
291+
292+
} else if expectedIdx < expectedCommonLastIdx && expectedIdx != 0 {
293+
movements = append(movements, MoveAction{
294+
EntryName: elt.EntryName(),
295+
Where: "after",
296+
Destination: firstElt.EntryName(),
297+
})
298+
firstElt = elt
299+
} else if expectedIdx >= expectedCommonLastIdx {
300+
movements = append(movements, MoveAction{
301+
EntryName: elt.EntryName(),
302+
Where: "after",
303+
Destination: afterElt.EntryName(),
304+
})
305+
afterElt = elt
306+
}
307+
}
308+
309+
return movements, nil
310+
}
311+
312+
func (o PositionTop) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
313+
entryNames := make(map[string]bool, len(entries))
314+
315+
for _, elt := range entries {
316+
entryNames[elt.EntryName()] = true
317+
}
318+
319+
filtered := removeEntriesFromExisting(existing, entries)
320+
321+
expected := append(entries, filtered...)
322+
323+
return GenerateMovements(existing, expected)
324+
}
325+
326+
func (o PositionBottom) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
327+
entryNames := make(map[string]bool, len(entries))
328+
329+
for _, elt := range entries {
330+
entryNames[elt.EntryName()] = true
331+
}
332+
333+
filtered := make([]Movable, len(existing))
334+
copy(filtered, existing)
335+
336+
filtered = slices.DeleteFunc(filtered, func(entry Movable) bool {
337+
_, ok := entryNames[entry.EntryName()]
338+
return ok
339+
})
340+
341+
expected := append(filtered, entries...)
342+
343+
return GenerateMovements(existing, expected)
344+
}
345+
346+
func MoveGroup(position Position, entries []Movable, existing []Movable) ([]MoveAction, error) {
347+
return position.Move(entries, existing)
348+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package movement_test
2+
3+
import (
4+
"log/slog"
5+
"testing"
6+
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
)
10+
11+
func TestMovement(t *testing.T) {
12+
handler := slog.NewTextHandler(GinkgoWriter, &slog.HandlerOptions{
13+
Level: slog.LevelDebug,
14+
})
15+
slog.SetDefault(slog.New(handler))
16+
RegisterFailHandler(Fail)
17+
RunSpecs(t, "Movement Suite")
18+
}

0 commit comments

Comments
 (0)