Skip to content

Commit

Permalink
feat: LCS-based movement implementation
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
kklimonda-cl committed Jul 31, 2024
1 parent 11eb059 commit 6af5f61
Show file tree
Hide file tree
Showing 3 changed files with 500 additions and 0 deletions.
348 changes: 348 additions & 0 deletions assets/pango/movement/movement.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
package movement

import (
"fmt"
"log/slog"
"slices"
)

var _ = slog.LevelDebug

type Movable interface {
EntryName() string
}

type MoveAction struct {
EntryName string
Where string
Destination string
}

type Position interface {
Move(entries []Movable, existing []Movable) ([]MoveAction, error)
}

type PositionTop struct{}

type PositionBottom struct{}

type PositionBefore struct {
Directly bool
Pivot Movable
}

type PositionAfter struct {
Directly bool
Pivot Movable
}

func removeEntriesFromExisting(existing []Movable, entries []Movable) []Movable {
entryNames := make(map[string]bool, len(entries))
for _, elt := range entries {
entryNames[elt.EntryName()] = true
}

filtered := make([]Movable, len(existing))
copy(filtered, existing)

filtered = slices.DeleteFunc(filtered, func(entry Movable) bool {
_, ok := entryNames[entry.EntryName()]
return ok
})

return filtered
}

func findPivotIdx(entries []Movable, pivot Movable) int {
return slices.IndexFunc(entries, func(entry Movable) bool {
if entry.EntryName() == pivot.EntryName() {
return true
}

return false
})

}

type movementType int

const (
movementBefore movementType = iota
movementAfter
)

func processPivotMovement(entries []Movable, existing []Movable, pivot Movable, direct bool, movement movementType) ([]MoveAction, error) {
existingLen := len(existing)
existingIdxMap := make(map[Movable]int, existingLen)

for idx, elt := range existing {
existingIdxMap[elt] = idx
}

pivotIdx := findPivotIdx(existing, pivot)
if pivotIdx == -1 {
return nil, fmt.Errorf("pivot point not found in the list of existing items")
}

if !direct {
movementRequired := false
entriesLen := len(entries)
loop:
for i := 0; i < entriesLen; i++ {

// For any given entry in the list of entries to move check if the entry
// index is at or after pivot point index, which will require movement
// set to be generated.
existingEntryIdx := existingIdxMap[entries[i]]
switch movement {
case movementBefore:
if existingEntryIdx >= pivotIdx {
movementRequired = true
break
}
case movementAfter:
if existingEntryIdx <= pivotIdx {
movementRequired = true
break
}
}

if i == 0 {
continue
}

// Check if the entries to be moved have the same order in the existing
// slice, and if not require a movement set to be generated.
switch movement {
case movementBefore:
if existingIdxMap[entries[i-1]] >= existingEntryIdx {
movementRequired = true
break loop

}
case movementAfter:
if existingIdxMap[entries[i-1]] <= existingEntryIdx {
movementRequired = true
break loop

}

}
}

if !movementRequired {
return nil, nil
}
}

expected := make([]Movable, len(existing))

filtered := removeEntriesFromExisting(existing, entries)
filteredPivotIdx := findPivotIdx(filtered, pivot)

switch movement {
case movementBefore:
expectedIdx := 0
for ; expectedIdx < filteredPivotIdx; expectedIdx++ {
expected[expectedIdx] = filtered[expectedIdx]
}

for _, elt := range entries {
expected[expectedIdx] = elt
expectedIdx++
}

expected[expectedIdx] = pivot
expectedIdx++

filteredLen := len(filtered)
for i := filteredPivotIdx + 1; i < filteredLen; i++ {
expected[expectedIdx] = filtered[i]
expectedIdx++
}
}

return GenerateMovements(existing, expected)
}

func (o PositionAfter) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
return processPivotMovement(entries, existing, o.Pivot, o.Directly, movementAfter)
}

func (o PositionBefore) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
return processPivotMovement(entries, existing, o.Pivot, o.Directly, movementBefore)
}

type Entry struct {
Element Movable
Expected int
Existing int
}

type sequencePosition struct {
Start int
End int
}

func longestCommonSubsequence(existing []Movable, expected []Movable) ([]Movable, sequencePosition) {
// Implementation of DP-based algorithm for solving LCP
// See https://en.wikipedia.org/wiki/Longest_common_subsequence for details
// and the algorithm.
m, n := len(existing), len(expected)

dp := make([][]int, m+1)
for i := range m + 1 {
dp[i] = make([]int, n+1)
}

for i := 0; i < m; i++ {
for j := 0; j < n; j++ {
if existing[i].EntryName() == expected[j].EntryName() {
dp[i+1][j+1] = 1 + dp[i][j]
} else {
dp[i+1][j+1] = max(dp[i+1][j], dp[i][j+1])
}
}
}

// Once LCS algorithm is finished we know the length of the longest common
// sequence (dp[m][n]) and we can then go backward in the matrix to build
// the actual sequence.
index := dp[m][n]

i := m
j := n

commonSubsequence := make([]Movable, index)

position := sequencePosition{
Start: i - index,
End: i - 1,
}

for {
if i == 0 || j == 0 {
break
}

if existing[i-1].EntryName() == expected[j-1].EntryName() {
commonSubsequence[index-1] = existing[i-1]

i--
j--
index--

} else if dp[i-1][j] > dp[i][j-1] {
i--
} else {
j--
}
}

return commonSubsequence, position
}

func GenerateMovements(existing []Movable, expected []Movable) ([]MoveAction, error) {
if len(existing) != len(expected) {
return nil, fmt.Errorf("existing length != expected length: %d != %d", len(existing), len(expected))
}

commonSequence, position := longestCommonSubsequence(existing, expected)

existingIdxMap := make(map[Movable]int, len(existing))
for idx, elt := range existing {
existingIdxMap[elt] = idx
}

expectedIdxMap := make(map[Movable]int, len(expected))
for idx, elt := range expected {
expectedIdxMap[elt] = idx
}

commonIdxMap := make(map[Movable]int, len(commonSequence))
for idx, elt := range commonSequence {
commonIdxMap[elt] = idx
}

var movements []MoveAction

expectedCommonLastIdx := expectedIdxMap[existing[position.End]]

firstElt := existing[0]
afterElt := existing[position.End]
for _, elt := range existing {
var ok bool
if _, ok = commonIdxMap[elt]; ok {
continue
}

existingIdx, _ := existingIdxMap[elt]
expectedIdx, _ := expectedIdxMap[elt]

if expectedIdx == existingIdx {
continue
} else if expectedIdx < expectedCommonLastIdx && expectedIdx == 0 {
movements = append(movements, MoveAction{
EntryName: elt.EntryName(),
Where: "top",
Destination: "top",
})
firstElt = elt

} else if expectedIdx < expectedCommonLastIdx && expectedIdx != 0 {
movements = append(movements, MoveAction{
EntryName: elt.EntryName(),
Where: "after",
Destination: firstElt.EntryName(),
})
firstElt = elt
} else if expectedIdx >= expectedCommonLastIdx {
movements = append(movements, MoveAction{
EntryName: elt.EntryName(),
Where: "after",
Destination: afterElt.EntryName(),
})
afterElt = elt
}
}

return movements, nil
}

func (o PositionTop) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
entryNames := make(map[string]bool, len(entries))

for _, elt := range entries {
entryNames[elt.EntryName()] = true
}

filtered := removeEntriesFromExisting(existing, entries)

expected := append(entries, filtered...)

return GenerateMovements(existing, expected)
}

func (o PositionBottom) Move(entries []Movable, existing []Movable) ([]MoveAction, error) {
entryNames := make(map[string]bool, len(entries))

for _, elt := range entries {
entryNames[elt.EntryName()] = true
}

filtered := make([]Movable, len(existing))
copy(filtered, existing)

filtered = slices.DeleteFunc(filtered, func(entry Movable) bool {
_, ok := entryNames[entry.EntryName()]
return ok
})

expected := append(filtered, entries...)

return GenerateMovements(existing, expected)
}

func MoveGroup(position Position, entries []Movable, existing []Movable) ([]MoveAction, error) {
return position.Move(entries, existing)
}
18 changes: 18 additions & 0 deletions assets/pango/movement/movement_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package movement_test

import (
"log/slog"
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestMovement(t *testing.T) {
handler := slog.NewTextHandler(GinkgoWriter, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
slog.SetDefault(slog.New(handler))
RegisterFailHandler(Fail)
RunSpecs(t, "Movement Suite")
}
Loading

0 comments on commit 6af5f61

Please sign in to comment.