From be3365bbe111b552708652205337b0f0db2fc208 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 substring 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 | 380 +++++++++++++++++++ assets/pango/movement/movement_suite_test.go | 18 + assets/pango/movement/movement_test.go | 147 +++++++ 3 files changed, 545 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..64036bcc --- /dev/null +++ b/assets/pango/movement/movement.go @@ -0,0 +1,380 @@ +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(entries []Movable, filterFn func(entry Movable) bool) []Movable { + entryNames := make(map[string]bool, len(entries)) + for _, elt := range entries { + entryNames[elt.EntryName()] = true + } + + filtered := make([]Movable, len(entries)) + copy(filtered, entries) + + filtered = slices.DeleteFunc(filtered, filterFn) + + 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)) + + entriesIdxMap := make(map[Movable]int, len(entries)) + for idx, elt := range entries { + entriesIdxMap[elt] = idx + } + + filtered := removeEntriesFromExisting(existing, func(entry Movable) bool { + _, ok := entriesIdxMap[entry] + return ok + }) + + 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, entries) +} + +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(S []Movable, T []Movable) [][]Movable { + + r := len(S) + n := len(T) + + L := make([][]int, r) + for idx := range len(T) { + L[idx] = make([]int, n) + } + z := 0 + + var results [][]Movable + + for i := 0; i < r; i++ { + for j := 0; j < n; j++ { + if S[i].EntryName() == T[j].EntryName() { + if i == 0 || j == 0 { + L[i][j] = 1 + } else { + L[i][j] = L[i-1][j-1] + 1 + } + + if L[i][j] > z { + slog.Debug("L[i][j] > z", "L[i][j]", L[i][j], "z", z, "i-z", i-z, "i", i) + results = nil + results = append(results, S[i-z:i+1]) + z = L[i][j] + slog.Debug("L[i][j] > z", "results", results) + } else if L[i][j] == z { + results = append(results, S[i-z:i+1]) + slog.Debug("L[i][j] == z", "i-z", i, "i", i+1) + } + slog.Debug("Still", "results", results) + } else { + L[i][j] = 0 + } + } + } + + slog.Debug("commonSubsequence", "results", results) + + return results +} + +func GenerateMovements(existing []Movable, expected []Movable, entries []Movable) ([]MoveAction, error) { + if len(existing) != len(expected) { + return nil, fmt.Errorf("existing length != expected length: %d != %d", len(existing), len(expected)) + } + + common := longestCommonSubsequence(existing, expected) + + entriesIdxMap := make(map[Movable]int, len(entries)) + for idx, elt := range entries { + entriesIdxMap[elt] = idx + } + + var commonSequence []Movable + for _, elt := range common { + filtered := removeEntriesFromExisting(elt, func(elt Movable) bool { + _, ok := entriesIdxMap[elt] + return ok + }) + + if len(filtered) > len(commonSequence) { + commonSequence = filtered + } + + } + + 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 + } + + commonLen := len(commonSequence) + commonIdxMap := make(map[Movable]int, len(commonSequence)) + for idx, elt := range commonSequence { + commonIdxMap[elt] = idx + } + + var movements []MoveAction + + var previous Movable + for _, elt := range entries { + slog.Debug("GenerateMovements", "elt", elt.EntryName(), "existingIdx", existingIdxMap[elt], "expectedIdx", expectedIdxMap[elt]) + if existingIdxMap[elt] == expectedIdxMap[elt] { + continue + } + + if expectedIdxMap[elt] == 0 { + movements = append(movements, MoveAction{ + EntryName: elt.EntryName(), + Destination: "top", + Where: "top", + }) + previous = elt + } else if len(commonSequence) > 0 { + if expectedIdxMap[elt] < expectedIdxMap[commonSequence[0]] { + if previous == nil { + previous = expected[0] + } + movements = append(movements, MoveAction{ + EntryName: elt.EntryName(), + Destination: previous.EntryName(), + Where: "after", + }) + previous = elt + } else if expectedIdxMap[elt] > expectedIdxMap[commonSequence[commonLen-1]] { + if previous == nil { + previous = commonSequence[commonLen-1] + } + movements = append(movements, MoveAction{ + EntryName: elt.EntryName(), + Destination: previous.EntryName(), + Where: "after", + }) + previous = elt + + } else if expectedIdxMap[elt] > expectedIdxMap[commonSequence[0]] { + if previous == nil { + previous = commonSequence[0] + } + movements = append(movements, MoveAction{ + EntryName: elt.EntryName(), + Destination: previous.EntryName(), + Where: "after", + }) + previous = elt + } + } else { + movements = append(movements, MoveAction{ + EntryName: elt.EntryName(), + Destination: previous.EntryName(), + Where: "after", + }) + previous = elt + } + + slog.Debug("GenerateMovements()", "existing", existingIdxMap[elt], "expected", expectedIdxMap[elt]) + } + + _ = previous + + slog.Debug("GenerateMovements()", "movements", movements) + + return movements, nil +} + +func (o PositionTop) Move(entries []Movable, existing []Movable) ([]MoveAction, error) { + entriesIdxMap := make(map[Movable]int, len(entries)) + for idx, elt := range entries { + entriesIdxMap[elt] = idx + } + + filtered := removeEntriesFromExisting(existing, func(entry Movable) bool { + _, ok := entriesIdxMap[entry] + return ok + }) + + expected := append(entries, filtered...) + + return GenerateMovements(existing, expected, entries) +} + +func (o PositionBottom) Move(entries []Movable, existing []Movable) ([]MoveAction, error) { + entriesIdxMap := make(map[Movable]int, len(entries)) + for idx, elt := range entries { + entriesIdxMap[elt] = idx + } + + filtered := removeEntriesFromExisting(existing, func(entry Movable) bool { + _, ok := entriesIdxMap[entry] + return ok + }) + + expected := append(filtered, entries...) + + return GenerateMovements(existing, expected, entries) +} + +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..a4322591 --- /dev/null +++ b/assets/pango/movement/movement_test.go @@ -0,0 +1,147 @@ +package movement_test + +import ( + . "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 three move actions", func() { + entries := asMovable([]string{"A", "B", "C"}) + existing := asMovable([]string{"D", "E", "A", "B", "C"}) + + moves, err := movement.MoveGroup(movement.PositionTop{}, entries, existing) + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(3)) + + Expect(moves[0].EntryName).To(Equal("A")) + Expect(moves[0].Where).To(Equal("top")) + Expect(moves[0].Destination).To(Equal("top")) + + Expect(moves[1].EntryName).To(Equal("B")) + Expect(moves[1].Where).To(Equal("after")) + Expect(moves[1].Destination).To(Equal("A")) + + Expect(moves[2].EntryName).To(Equal("C")) + Expect(moves[2].Where).To(Equal("after")) + Expect(moves[2].Destination).To(Equal("B")) + }) + }) + 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()) + + Expect(moves).To(HaveLen(4)) + }) + }) + }) + Context("With PositionBottom used as position", func() { + Context("when it needs to move one element", func() { + It("should generate a single move action", func() { + entries := asMovable([]string{"E"}) + existing := asMovable([]string{"A", "E", "B", "C", "D"}) + + moves, err := movement.MoveGroup(movement.PositionBottom{}, entries, 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"}) + + 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() { + entries := asMovable([]string{"A", "B"}) + 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() { + // A B C D E -> A C B D E + 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(2)) + Expect(moves[0].EntryName).To(Equal("C")) + Expect(moves[0].Where).To(Equal("after")) + Expect(moves[0].Destination).To(Equal("A")) + + Expect(moves[1].EntryName).To(Equal("B")) + Expect(moves[1].Where).To(Equal("after")) + Expect(moves[1].Destination).To(Equal("C")) + }) + }) + }) + Context("when direct position relative to the pivot is required", func() { + It("should generate required move actions", func() { + // A B C D E -> C A B D E + entries := asMovable([]string{"A", "B"}) + moves, err := movement.MoveGroup( + movement.PositionBefore{Directly: true, Pivot: Mock{"D"}}, + entries, existing, + ) + + Expect(err).ToNot(HaveOccurred()) + Expect(moves).To(HaveLen(2)) + + Expect(moves[0].EntryName).To(Equal("A")) + Expect(moves[0].Where).To(Equal("after")) + Expect(moves[0].Destination).To(Equal("C")) + + Expect(moves[1].EntryName).To(Equal("B")) + Expect(moves[1].Where).To(Equal("after")) + Expect(moves[1].Destination).To(Equal("A")) + }) + }) + }) +})