Rewrite filter.Apply as single-pass with early-exit and pre-allocation

Replace the multi-pass where() chain in Apply() with a single loop that
evaluates all filter predicates per item and skips immediately on first
mismatch. This eliminates N intermediate slice allocations (one per
active filter) and avoids re-scanning the full dataset for each filter
dimension.

Key changes in filter.go:
- Single loop with continue-on-mismatch for BOGO, category, department,
  and query filters — combined categories check scans item.Categories
  once for both BOGO and category instead of twice
- Pre-allocate result slice capped at min(len(items), opts.Limit) to
  avoid grow-and-copy churn
- Fast-path bypass when no filters are active (just apply limit)
- Break early once limit is reached instead of filtering everything
  and truncating after
- Remove the now-unused where() helper function
- Add early-return fast paths to CleanText() for the common case where
  input contains no HTML entities or newlines, avoiding unnecessary
  html.UnescapeString and ReplaceAll calls

Test coverage:
- filter_equivalence_test.go (new): Reference implementation of the
  original multi-pass algorithm with 500 randomized test cases verifying
  behavioral equivalence. Includes allocation budget guardrail (<=80
  allocs/op for 1k items) to catch accidental regression to multi-pass.
  Benchmarks for new vs legacy reference on identical workload.
- filter_test.go: Benchmark comparisons for CleanText on plain text
  (fast path) vs escaped HTML (full path), new vs legacy.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 00:11:38 -05:00
parent 4310375dc9
commit df0af4a5f8
3 changed files with 307 additions and 36 deletions

View File

@@ -18,40 +18,72 @@ type Options struct {
// Apply filters a slice of SavingItems according to the given options.
func Apply(items []api.SavingItem, opts Options) []api.SavingItem {
result := items
wantCategory := opts.Category != ""
wantDepartment := opts.Department != ""
wantQuery := opts.Query != ""
needsFiltering := opts.BOGO || wantCategory || wantDepartment || wantQuery
if opts.BOGO {
result = where(result, func(i api.SavingItem) bool {
return ContainsIgnoreCase(i.Categories, "bogo")
})
if !needsFiltering {
if opts.Limit > 0 && opts.Limit < len(items) {
return items[:opts.Limit]
}
return items
}
if opts.Category != "" {
result = where(result, func(i api.SavingItem) bool {
return ContainsIgnoreCase(i.Categories, opts.Category)
})
var result []api.SavingItem
if opts.Limit > 0 && opts.Limit < len(items) {
result = make([]api.SavingItem, 0, opts.Limit)
} else {
result = make([]api.SavingItem, 0, len(items))
}
if opts.Department != "" {
dept := strings.ToLower(opts.Department)
result = where(result, func(i api.SavingItem) bool {
return strings.Contains(strings.ToLower(Deref(i.Department)), dept)
})
category := opts.Category
department := strings.ToLower(opts.Department)
query := strings.ToLower(opts.Query)
for _, item := range items {
if opts.BOGO || wantCategory {
hasBogo := !opts.BOGO
hasCategory := !wantCategory
for _, c := range item.Categories {
if !hasBogo && strings.EqualFold(c, "bogo") {
hasBogo = true
}
if !hasCategory && strings.EqualFold(c, category) {
hasCategory = true
}
if hasBogo && hasCategory {
break
}
}
if !hasBogo || !hasCategory {
continue
}
}
if wantDepartment && !strings.Contains(strings.ToLower(Deref(item.Department)), department) {
continue
}
if wantQuery {
title := strings.ToLower(CleanText(Deref(item.Title)))
desc := strings.ToLower(CleanText(Deref(item.Description)))
if !strings.Contains(title, query) && !strings.Contains(desc, query) {
continue
}
}
result = append(result, item)
if opts.Limit > 0 && len(result) >= opts.Limit {
break
}
}
if opts.Query != "" {
q := strings.ToLower(opts.Query)
result = where(result, func(i api.SavingItem) bool {
title := strings.ToLower(CleanText(Deref(i.Title)))
desc := strings.ToLower(CleanText(Deref(i.Description)))
return strings.Contains(title, q) || strings.Contains(desc, q)
})
if len(result) == 0 {
return nil
}
if opts.Limit > 0 && opts.Limit < len(result) {
result = result[:opts.Limit]
}
return result
}
@@ -76,23 +108,21 @@ func Deref(s *string) string {
// CleanText unescapes HTML entities and normalizes whitespace.
func CleanText(s string) string {
if !strings.ContainsAny(s, "&\r\n") {
return strings.TrimSpace(s)
}
s = html.UnescapeString(s)
if !strings.ContainsAny(s, "\r\n") {
return strings.TrimSpace(s)
}
s = strings.ReplaceAll(s, "\r\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
s = strings.ReplaceAll(s, "\n", " ")
return strings.TrimSpace(s)
}
func where(items []api.SavingItem, fn func(api.SavingItem) bool) []api.SavingItem {
var result []api.SavingItem
for _, item := range items {
if fn(item) {
result = append(result, item)
}
}
return result
}
// ContainsIgnoreCase reports whether any element in slice matches val case-insensitively.
func ContainsIgnoreCase(slice []string, val string) bool {
for _, s := range slice {