refactor: remove map lookups from history ranking sort
- commit
- 46e2e0d37194eb05ddda364d17888828477c55f6
- author
- brice · · 2026-03-11 03:58 UTC
- parent
- 4524185
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +145 -24
M internal/infrastructure/persistence/sqlite/history_repo.go +47 -24
| @@ -210,43 +210,66 @@ func rankHistoryMatches( | |||
| 210 | 210 | } | |
| 211 | 211 | ||
| 212 | 212 | queryLower := strings.ToLower(strings.TrimSpace(query)) | |
| 213 | - | matches := make([]entity.HistoryMatch, 0, len(candidates)) | |
| 213 | + | ranked := make([]rankedHistoryMatch, 0, len(candidates)) | |
| 214 | 214 | for _, candidate := range candidates { | |
| 215 | 215 | candidate.score = computeHistoryCandidateScore(candidate, queryLower, words) | |
| 216 | - | matches = append(matches, entity.HistoryMatch{ | |
| 217 | - | Entry: candidate.entry, | |
| 218 | - | Score: candidate.score, | |
| 216 | + | ranked = append(ranked, rankedHistoryMatch{ | |
| 217 | + | match: entity.HistoryMatch{ | |
| 218 | + | Entry: candidate.entry, | |
| 219 | + | Score: candidate.score, | |
| 220 | + | }, | |
| 221 | + | score: candidate.score, | |
| 222 | + | exactURLMatch: candidate.exactURLMatch, | |
| 223 | + | prefixURL: candidate.prefixURL, | |
| 224 | + | urlMatch: candidate.urlMatch, | |
| 225 | + | prefixTitle: candidate.prefixTitle, | |
| 219 | 226 | }) | |
| 220 | 227 | } | |
| 221 | 228 | ||
| 222 | - | sort.Slice(matches, func(i, j int) bool { | |
| 223 | - | left := candidates[matches[i].Entry.ID] | |
| 224 | - | right := candidates[matches[j].Entry.ID] | |
| 225 | - | if matches[i].Score != matches[j].Score { | |
| 226 | - | return matches[i].Score > matches[j].Score | |
| 229 | + | sortRankedHistoryMatches(ranked) | |
| 230 | + | ||
| 231 | + | matches := make([]entity.HistoryMatch, len(ranked)) | |
| 232 | + | for i := range ranked { | |
| 233 | + | matches[i] = ranked[i].match | |
| 234 | + | } | |
| 235 | + | return matches | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | type rankedHistoryMatch struct { | |
| 239 | + | match entity.HistoryMatch | |
| 240 | + | score float64 | |
| 241 | + | exactURLMatch bool | |
| 242 | + | prefixURL bool | |
| 243 | + | urlMatch bool | |
| 244 | + | prefixTitle bool | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | func sortRankedHistoryMatches(items []rankedHistoryMatch) { | |
| 248 | + | sort.Slice(items, func(i, j int) bool { | |
| 249 | + | li, ri := &items[i], &items[j] | |
| 250 | + | if li.score != ri.score { | |
| 251 | + | return li.score > ri.score | |
| 227 | 252 | } | |
| 228 | - | if left.exactURLMatch != right.exactURLMatch { | |
| 229 | - | return left.exactURLMatch | |
| 253 | + | if li.exactURLMatch != ri.exactURLMatch { | |
| 254 | + | return li.exactURLMatch | |
| 230 | 255 | } | |
| 231 | - | if left.prefixURL != right.prefixURL { | |
| 232 | - | return left.prefixURL | |
| 256 | + | if li.prefixURL != ri.prefixURL { | |
| 257 | + | return li.prefixURL | |
| 233 | 258 | } | |
| 234 | - | if left.urlMatch != right.urlMatch { | |
| 235 | - | return left.urlMatch | |
| 259 | + | if li.urlMatch != ri.urlMatch { | |
| 260 | + | return li.urlMatch | |
| 236 | 261 | } | |
| 237 | - | if left.prefixTitle != right.prefixTitle { | |
| 238 | - | return left.prefixTitle | |
| 262 | + | if li.prefixTitle != ri.prefixTitle { | |
| 263 | + | return li.prefixTitle | |
| 239 | 264 | } | |
| 240 | - | if matches[i].Entry.VisitCount != matches[j].Entry.VisitCount { | |
| 241 | - | return matches[i].Entry.VisitCount > matches[j].Entry.VisitCount | |
| 265 | + | if li.match.Entry.VisitCount != ri.match.Entry.VisitCount { | |
| 266 | + | return li.match.Entry.VisitCount > ri.match.Entry.VisitCount | |
| 242 | 267 | } | |
| 243 | - | if !matches[i].Entry.LastVisited.Equal(matches[j].Entry.LastVisited) { | |
| 244 | - | return matches[i].Entry.LastVisited.After(matches[j].Entry.LastVisited) | |
| 268 | + | if !li.match.Entry.LastVisited.Equal(ri.match.Entry.LastVisited) { | |
| 269 | + | return li.match.Entry.LastVisited.After(ri.match.Entry.LastVisited) | |
| 245 | 270 | } | |
| 246 | - | return matches[i].Entry.URL < matches[j].Entry.URL | |
| 271 | + | return li.match.Entry.URL < ri.match.Entry.URL | |
| 247 | 272 | }) | |
| 248 | - | ||
| 249 | - | return matches | |
| 250 | 273 | } | |
| 251 | 274 | ||
| 252 | 275 | func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate { | |
| @@ -210,43 +210,66 @@ func rankHistoryMatches( | |||||
| 210 | } | 210 | } | ||
| 211 | 211 | ||||
| 212 | queryLower := strings.ToLower(strings.TrimSpace(query)) | 212 | queryLower := strings.ToLower(strings.TrimSpace(query)) | ||
| 213 | - | matches := make([]entity.HistoryMatch, 0, len(candidates)) | 213 | + | ranked := make([]rankedHistoryMatch, 0, len(candidates)) |
| 214 | for _, candidate := range candidates { | 214 | for _, candidate := range candidates { | ||
| 215 | candidate.score = computeHistoryCandidateScore(candidate, queryLower, words) | 215 | candidate.score = computeHistoryCandidateScore(candidate, queryLower, words) | ||
| 216 | - | matches = append(matches, entity.HistoryMatch{ | 216 | + | ranked = append(ranked, rankedHistoryMatch{ |
| 217 | - | Entry: candidate.entry, | 217 | + | match: entity.HistoryMatch{ |
| 218 | - | Score: candidate.score, | 218 | + | Entry: candidate.entry, |
| 219 | + | Score: candidate.score, | |||
| 220 | + | }, | |||
| 221 | + | score: candidate.score, | |||
| 222 | + | exactURLMatch: candidate.exactURLMatch, | |||
| 223 | + | prefixURL: candidate.prefixURL, | |||
| 224 | + | urlMatch: candidate.urlMatch, | |||
| 225 | + | prefixTitle: candidate.prefixTitle, | |||
| 219 | }) | 226 | }) | ||
| 220 | } | 227 | } | ||
| 221 | 228 | ||||
| 222 | - | sort.Slice(matches, func(i, j int) bool { | 229 | + | sortRankedHistoryMatches(ranked) |
| 223 | - | left := candidates[matches[i].Entry.ID] | 230 | + | |
| 224 | - | right := candidates[matches[j].Entry.ID] | 231 | + | matches := make([]entity.HistoryMatch, len(ranked)) |
| 225 | - | if matches[i].Score != matches[j].Score { | 232 | + | for i := range ranked { |
| 226 | - | return matches[i].Score > matches[j].Score | 233 | + | matches[i] = ranked[i].match |
| 234 | + | } | |||
| 235 | + | return matches | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | type rankedHistoryMatch struct { | |||
| 239 | + | match entity.HistoryMatch | |||
| 240 | + | score float64 | |||
| 241 | + | exactURLMatch bool | |||
| 242 | + | prefixURL bool | |||
| 243 | + | urlMatch bool | |||
| 244 | + | prefixTitle bool | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | func sortRankedHistoryMatches(items []rankedHistoryMatch) { | |||
| 248 | + | sort.Slice(items, func(i, j int) bool { | |||
| 249 | + | li, ri := &items[i], &items[j] | |||
| 250 | + | if li.score != ri.score { | |||
| 251 | + | return li.score > ri.score | |||
| 227 | } | 252 | } | ||
| 228 | - | if left.exactURLMatch != right.exactURLMatch { | 253 | + | if li.exactURLMatch != ri.exactURLMatch { |
| 229 | - | return left.exactURLMatch | 254 | + | return li.exactURLMatch |
| 230 | } | 255 | } | ||
| 231 | - | if left.prefixURL != right.prefixURL { | 256 | + | if li.prefixURL != ri.prefixURL { |
| 232 | - | return left.prefixURL | 257 | + | return li.prefixURL |
| 233 | } | 258 | } | ||
| 234 | - | if left.urlMatch != right.urlMatch { | 259 | + | if li.urlMatch != ri.urlMatch { |
| 235 | - | return left.urlMatch | 260 | + | return li.urlMatch |
| 236 | } | 261 | } | ||
| 237 | - | if left.prefixTitle != right.prefixTitle { | 262 | + | if li.prefixTitle != ri.prefixTitle { |
| 238 | - | return left.prefixTitle | 263 | + | return li.prefixTitle |
| 239 | } | 264 | } | ||
| 240 | - | if matches[i].Entry.VisitCount != matches[j].Entry.VisitCount { | 265 | + | if li.match.Entry.VisitCount != ri.match.Entry.VisitCount { |
| 241 | - | return matches[i].Entry.VisitCount > matches[j].Entry.VisitCount | 266 | + | return li.match.Entry.VisitCount > ri.match.Entry.VisitCount |
| 242 | } | 267 | } | ||
| 243 | - | if !matches[i].Entry.LastVisited.Equal(matches[j].Entry.LastVisited) { | 268 | + | if !li.match.Entry.LastVisited.Equal(ri.match.Entry.LastVisited) { |
| 244 | - | return matches[i].Entry.LastVisited.After(matches[j].Entry.LastVisited) | 269 | + | return li.match.Entry.LastVisited.After(ri.match.Entry.LastVisited) |
| 245 | } | 270 | } | ||
| 246 | - | return matches[i].Entry.URL < matches[j].Entry.URL | 271 | + | return li.match.Entry.URL < ri.match.Entry.URL |
| 247 | }) | 272 | }) | ||
| 248 | - | ||||
| 249 | - | return matches | |||
| 250 | } | 273 | } | ||
| 251 | 274 | ||||
| 252 | func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate { | 275 | func ensureHistoryCandidate(candidates map[int64]*historySearchCandidate, entry *entity.HistoryEntry) *historySearchCandidate { | ||
A internal/infrastructure/persistence/sqlite/history_repo_rank_test.go +98 -0
| @@ -0,0 +1,98 @@ | |||
| 1 | + | package sqlite | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestSortRankedHistoryMatches(t *testing.T) { | |
| 11 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |
| 12 | + | older := now.Add(-24 * time.Hour) | |
| 13 | + | ||
| 14 | + | items := []rankedHistoryMatch{ | |
| 15 | + | { | |
| 16 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 1, URL: "z-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |
| 17 | + | score: 10, | |
| 18 | + | exactURLMatch: false, | |
| 19 | + | prefixURL: false, | |
| 20 | + | urlMatch: false, | |
| 21 | + | prefixTitle: false, | |
| 22 | + | }, | |
| 23 | + | { | |
| 24 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 2, URL: "a-url", VisitCount: 5, LastVisited: now}, Score: 20}, | |
| 25 | + | score: 20, | |
| 26 | + | exactURLMatch: false, | |
| 27 | + | prefixURL: false, | |
| 28 | + | urlMatch: false, | |
| 29 | + | prefixTitle: false, | |
| 30 | + | }, | |
| 31 | + | { | |
| 32 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 3, URL: "b-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |
| 33 | + | score: 10, | |
| 34 | + | exactURLMatch: true, | |
| 35 | + | prefixURL: false, | |
| 36 | + | urlMatch: false, | |
| 37 | + | prefixTitle: false, | |
| 38 | + | }, | |
| 39 | + | { | |
| 40 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 4, URL: "c-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |
| 41 | + | score: 10, | |
| 42 | + | exactURLMatch: false, | |
| 43 | + | prefixURL: true, | |
| 44 | + | urlMatch: false, | |
| 45 | + | prefixTitle: false, | |
| 46 | + | }, | |
| 47 | + | { | |
| 48 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 5, URL: "d-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |
| 49 | + | score: 10, | |
| 50 | + | exactURLMatch: false, | |
| 51 | + | prefixURL: false, | |
| 52 | + | urlMatch: true, | |
| 53 | + | prefixTitle: false, | |
| 54 | + | }, | |
| 55 | + | { | |
| 56 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 6, URL: "e-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |
| 57 | + | score: 10, | |
| 58 | + | exactURLMatch: false, | |
| 59 | + | prefixURL: false, | |
| 60 | + | urlMatch: false, | |
| 61 | + | prefixTitle: true, | |
| 62 | + | }, | |
| 63 | + | { | |
| 64 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 7, URL: "f-url", VisitCount: 10, LastVisited: now}, Score: 10}, | |
| 65 | + | score: 10, | |
| 66 | + | exactURLMatch: false, | |
| 67 | + | prefixURL: false, | |
| 68 | + | urlMatch: false, | |
| 69 | + | prefixTitle: false, | |
| 70 | + | }, | |
| 71 | + | { | |
| 72 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 8, URL: "g-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |
| 73 | + | score: 10, | |
| 74 | + | exactURLMatch: false, | |
| 75 | + | prefixURL: false, | |
| 76 | + | urlMatch: false, | |
| 77 | + | prefixTitle: false, | |
| 78 | + | }, | |
| 79 | + | { | |
| 80 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 9, URL: "h-url", VisitCount: 5, LastVisited: older}, Score: 10}, | |
| 81 | + | score: 10, | |
| 82 | + | exactURLMatch: false, | |
| 83 | + | prefixURL: false, | |
| 84 | + | urlMatch: false, | |
| 85 | + | prefixTitle: false, | |
| 86 | + | }, | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | sortRankedHistoryMatches(items) | |
| 90 | + | ||
| 91 | + | wantOrder := []int64{2, 3, 4, 5, 6, 7, 8, 1, 9} | |
| 92 | + | for i, want := range wantOrder { | |
| 93 | + | got := items[i].match.Entry.ID | |
| 94 | + | if got != want { | |
| 95 | + | t.Errorf("position %d: want ID %d, got ID %d", i, want, got) | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | } | |
| @@ -0,0 +1,98 @@ | |||||
| 1 | + | package sqlite | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestSortRankedHistoryMatches(t *testing.T) { | |||
| 11 | + | now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) | |||
| 12 | + | older := now.Add(-24 * time.Hour) | |||
| 13 | + | ||||
| 14 | + | items := []rankedHistoryMatch{ | |||
| 15 | + | { | |||
| 16 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 1, URL: "z-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |||
| 17 | + | score: 10, | |||
| 18 | + | exactURLMatch: false, | |||
| 19 | + | prefixURL: false, | |||
| 20 | + | urlMatch: false, | |||
| 21 | + | prefixTitle: false, | |||
| 22 | + | }, | |||
| 23 | + | { | |||
| 24 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 2, URL: "a-url", VisitCount: 5, LastVisited: now}, Score: 20}, | |||
| 25 | + | score: 20, | |||
| 26 | + | exactURLMatch: false, | |||
| 27 | + | prefixURL: false, | |||
| 28 | + | urlMatch: false, | |||
| 29 | + | prefixTitle: false, | |||
| 30 | + | }, | |||
| 31 | + | { | |||
| 32 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 3, URL: "b-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |||
| 33 | + | score: 10, | |||
| 34 | + | exactURLMatch: true, | |||
| 35 | + | prefixURL: false, | |||
| 36 | + | urlMatch: false, | |||
| 37 | + | prefixTitle: false, | |||
| 38 | + | }, | |||
| 39 | + | { | |||
| 40 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 4, URL: "c-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |||
| 41 | + | score: 10, | |||
| 42 | + | exactURLMatch: false, | |||
| 43 | + | prefixURL: true, | |||
| 44 | + | urlMatch: false, | |||
| 45 | + | prefixTitle: false, | |||
| 46 | + | }, | |||
| 47 | + | { | |||
| 48 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 5, URL: "d-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |||
| 49 | + | score: 10, | |||
| 50 | + | exactURLMatch: false, | |||
| 51 | + | prefixURL: false, | |||
| 52 | + | urlMatch: true, | |||
| 53 | + | prefixTitle: false, | |||
| 54 | + | }, | |||
| 55 | + | { | |||
| 56 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 6, URL: "e-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |||
| 57 | + | score: 10, | |||
| 58 | + | exactURLMatch: false, | |||
| 59 | + | prefixURL: false, | |||
| 60 | + | urlMatch: false, | |||
| 61 | + | prefixTitle: true, | |||
| 62 | + | }, | |||
| 63 | + | { | |||
| 64 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 7, URL: "f-url", VisitCount: 10, LastVisited: now}, Score: 10}, | |||
| 65 | + | score: 10, | |||
| 66 | + | exactURLMatch: false, | |||
| 67 | + | prefixURL: false, | |||
| 68 | + | urlMatch: false, | |||
| 69 | + | prefixTitle: false, | |||
| 70 | + | }, | |||
| 71 | + | { | |||
| 72 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 8, URL: "g-url", VisitCount: 5, LastVisited: now}, Score: 10}, | |||
| 73 | + | score: 10, | |||
| 74 | + | exactURLMatch: false, | |||
| 75 | + | prefixURL: false, | |||
| 76 | + | urlMatch: false, | |||
| 77 | + | prefixTitle: false, | |||
| 78 | + | }, | |||
| 79 | + | { | |||
| 80 | + | match: entity.HistoryMatch{Entry: &entity.HistoryEntry{ID: 9, URL: "h-url", VisitCount: 5, LastVisited: older}, Score: 10}, | |||
| 81 | + | score: 10, | |||
| 82 | + | exactURLMatch: false, | |||
| 83 | + | prefixURL: false, | |||
| 84 | + | urlMatch: false, | |||
| 85 | + | prefixTitle: false, | |||
| 86 | + | }, | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | sortRankedHistoryMatches(items) | |||
| 90 | + | ||||
| 91 | + | wantOrder := []int64{2, 3, 4, 5, 6, 7, 8, 1, 9} | |||
| 92 | + | for i, want := range wantOrder { | |||
| 93 | + | got := items[i].match.Entry.ID | |||
| 94 | + | if got != want { | |||
| 95 | + | t.Errorf("position %d: want ID %d, got ID %d", i, want, got) | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } | |||