From 6af5f617ad4e8a3d3c678e3a79834d156ca97d32 Mon Sep 17 00:00:00 2001 From: Krzysztof Klimonda Date: Wed, 31 Jul 2024 13:11:00 +0200 Subject: [PATCH] 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. --- assets/pango/movement/movement.go | 348 +++++++++++++++++++ assets/pango/movement/movement_suite_test.go | 18 + assets/pango/movement/movement_test.go | 134 +++++++ 3 files changed, 500 insertions(+) create mode 100644 assets/pango/movement/movement.go create mode 100644 assets/pango/movement/movement_suite_test.go create mode 100644 assets/pango/movement/movement_test.go diff --git a/assets/pango/movement/movement.go b/assets/pango/movement/movement.go new file mode 100644 index 00000000..af2edc21 --- /dev/null +++ b/assets/pango/movement/movement.go @@ -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) +} diff --git a/assets/pango/movement/movement_suite_test.go b/assets/pango/movement/movement_suite_test.go new file mode 100644 index 00000000..b750b000 --- /dev/null +++ b/assets/pango/movement/movement_suite_test.go @@ -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") +} diff --git a/assets/pango/movement/movement_test.go b/assets/pango/movement/movement_test.go new file mode 100644 index 00000000..43aeefa6 --- /dev/null +++ b/assets/pango/movement/movement_test.go @@ -0,0 +1,134 @@ +package movement_test + +import ( + "log/slog" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "movements/movement" +) + +type Mock struct { + Name string +} + +func (o Mock) EntryName() string { + return o.Name +} + +func asMovable(mocks []string) []movement.Movable { + var movables []movement.Movable + + for _, elt := range mocks { + movables = append(movables, Mock{elt}) + } + + return movables +} + +var _ = Describe("Movement", func() { + Context("With PositionTop used as position", func() { + Context("when existing positions matches expected", func() { + It("should generate no movements", func() { + expected := asMovable([]string{"A", "B", "C"}) + moves, err := movement.MoveGroup(movement.PositionTop{}, expected, expected) + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(0)) + }) + }) + Context("when it has to move two elements", func() { + It("should generate two move actions", func() { + group := asMovable([]string{"A", "B", "C"}) + existing := asMovable([]string{"D", "E", "A", "B", "C"}) + + moves, err := movement.MoveGroup(movement.PositionTop{}, group, existing) + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(2)) + + Expect(moves[0].EntryName).To(Equal("D")) + Expect(moves[0].Where).To(Equal("after")) + Expect(moves[0].Destination).To(Equal("C")) + + Expect(moves[1].EntryName).To(Equal("E")) + Expect(moves[1].Where).To(Equal("after")) + Expect(moves[1].Destination).To(Equal("D")) + + }) + }) + Context("when expected order is reversed", func() { + It("should generate required move actions to converge lists", func() { + entries := asMovable([]string{"E", "D", "C", "B", "A"}) + existing := asMovable([]string{"A", "B", "C", "D", "E"}) + moves, err := movement.MoveGroup(movement.PositionTop{}, entries, existing) + Expect(err).ToNot(HaveOccurred()) + + slog.Debug("Moves", "moves", moves) + Expect(moves).To(HaveLen(3)) + }) + }) + }) + Context("With PositionBottom used as position", func() { + Context("when it needs to move one element", func() { + It("should generate a single move action", func() { + group := asMovable([]string{"E"}) + existing := asMovable([]string{"A", "E", "B", "C", "D"}) + + moves, err := movement.MoveGroup(movement.PositionBottom{}, group, existing) + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(1)) + + Expect(moves[0].EntryName).To(Equal("E")) + Expect(moves[0].Where).To(Equal("after")) + Expect(moves[0].Destination).To(Equal("D")) + }) + }) + }) + + Context("With PositionBefore used as position", func() { + existing := asMovable([]string{"A", "B", "C", "D", "E"}) + entries := asMovable([]string{"A", "B"}) + Context("when direct position relative to the pivot is not required", func() { + Context("and moved entries are already before pivot point", func() { + It("should not generate any move actions", func() { + moves, err := movement.MoveGroup( + movement.PositionBefore{Directly: false, Pivot: Mock{"D"}}, + entries, existing, + ) + + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(0)) + }) + }) + Context("and moved entries are out of order", func() { + It("should generate only move commands to sort entries", func() { + entries := asMovable([]string{"C", "B"}) + moves, err := movement.MoveGroup( + movement.PositionBefore{Directly: false, Pivot: Mock{"D"}}, + entries, existing, + ) + + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(1)) + Expect(moves[0].EntryName).To(Equal("B")) + Expect(moves[0].Where).To(Equal("after")) + Expect(moves[0].Destination).To(Equal("A")) + }) + }) + }) + Context("when direct position relative to the pivot is required", func() { + It("should generate required move actions", func() { + moves, err := movement.MoveGroup( + movement.PositionBefore{Directly: true, Pivot: Mock{"D"}}, + entries, existing, + ) + + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(1)) + Expect(moves[0].EntryName).To(Equal("C")) + Expect(moves[0].Where).To(Equal("top")) + Expect(moves[0].Destination).To(Equal("top")) + }) + }) + }) +})