Skip to content

perf(feed): precompute per-mission search index instead of re-normalizing on every keystroke - #334

Merged
guyghost merged 1 commit into
developfrom
guyghost-precompute-feed-search-index
Aug 28, 2026
Merged

perf(feed): precompute per-mission search index instead of re-normalizing on every keystroke#334
guyghost merged 1 commit into
developfrom
guyghost-precompute-feed-search-index

Conversation

@guyghost

@guyghost guyghost commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Finding (audit point #10, from PR #327's audit table)

apps/extension/src/lib/state/feed.svelte.tsrecomputeFilteredMissions built its search haystack per mission on every search keystroke: it lowercased and space-joined the full mission description (~4 KB) plus title/client/location/source/stack for each mission, on each keystroke. With N missions that's ~N×4 KB of string join + lowercase work per keypress, repeated for every character typed.

Approach

  • Extract the haystack construction into a pure helper buildSearchHaystack(mission)byte-for-byte the same field list, order, non-empty-string filter, ' ' join, and toLowerCase() as the previous inline logic.
  • In the store factory, precompute the index once per missions change:
    const searchIndex = $derived(missions.map(buildSearchHaystack));
    const filteredMissions = $deduced... = $derived(recomputeFilteredMissions(missions, searchIndex, searchQuery));
    $derived is lazy and reactive: the index is rebuilt only when missions changes, never on searchQuery changes. Dependency ordering guarantees index/missions positional alignment.
  • recomputeFilteredMissions(missions, searchIndex, searchQuery) now only trims/lowercases the query and does searchIndex[index].includes(query) — a plain substring scan over pre-normalized strings.
  • Plain string[] on purpose — no reactive collection needed (rebuilt wholesale on missions change), so the svelte/prefer-svelte-reactivity rule isn't triggered and no file-level disable is required.

Before → after per-keystroke cost

Before After
Per keystroke N × (filter + join + lowercase of ~4 KB haystack) N × substring includes on precomputed strings
Per missions change N × (join + lowercase), once

Memory tradeoff: the index holds ~one lowercased haystack (~size of mission text) per mission in memory. Bounded by the feed size and rebuilt wholesale on missions change — acceptable for a local-first extension feed.

Semantics preservation

Search behavior is identical by construction:

  • same fields searched, in the same order: [title, client, description, location, source, ...stack]
  • same non-empty-string filter and ' ' join → multi-word queries can still match across field boundaries (joined-haystack semantics preserved)
  • same case-insensitive matching (haystack lowercased once instead of per keystroke; query trim().toLowerCase() — equivalent to the previous toLowerCase().trim() since case mapping never produces/removes whitespace)
  • substring matching (includes) preserved — no tokenization, whole-word, or diacritic-folding changes
  • empty/whitespace-only query still returns missions unfiltered

A dedicated equivalence test (produces identical results to the previous per-keystroke normalization) runs a corpus of 12 representative queries — uppercase, multi-word, cross-field, whitespace-padded, absent terms, empty — against a local replica of the previous inline algorithm and asserts identical results.

House rule: Model → Review → Implement → Verify

This is a behavior-identical performance refactor of the feed search path: no states, events, transitions, or side effects change; filtering decisions are unchanged and remain entirely deterministic model/state logic (no LLM involvement). The authoritative model (src/models/feed-story.model.md) specifies search only at the observable level, which this PR preserves exactly — no model update required.

Tests

Extended tests/unit/state/feed.test.ts with a precomputed search index suite:

  • index reused across successive searches on the same missions
  • index rebuilt when missions change (query persists across setMissions)
  • multi-word queries spanning field boundaries (joined-haystack semantics)
  • case-insensitivity for both query and mission fields
  • description-only matches
  • whitespace-only queries treated as empty; surrounding whitespace trimmed
  • equivalence with the previous per-keystroke normalization

Verification

  • pnpm --filter @pulse/extension exec vitest run tests/unit/state/feed.test.ts18/18 passed
  • pnpm --filter @pulse/extension typecheck → clean
  • pnpm --filter @pulse/extension lint → 0 errors (8 pre-existing warnings in local-data-reset.contract.ts, untouched)
  • Full suite → 4079 passed / 51 skipped, with one pre-existing flaky timeout (local-data-reset-machine.model.test.ts "rehydrates cleanup after adoption…") reproduced identically on the pristine baseline commit — unrelated to this change
  • Final push ran the full pre-push gate (ci:check: format + lint + typecheck + test + build) — green
  • prettier applied to both touched files

Closes audit point #10.


Devin Review

…zing on every keystroke

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI lite review requested due to automatic review settings August 28, 2026 09:37
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
pulse Ready Ready Preview Aug 28, 2026 9:38am
1 Skipped Deployment
Project Deployment Actions Updated (UTC)
pulse-dashboard Skipped Skipped Aug 28, 2026 9:38am

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Devin Review

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The refactor is small, behavior-preserving, and is backed by targeted unit tests that assert equivalence with the prior search implementation.

Pull request overview

This PR optimizes the extension feed’s client-side search filtering by precomputing a per-mission normalized search “haystack” when missions change, instead of rebuilding/lowercasing/joining large strings on every keystroke. It fits into the apps/extension/src/lib/state/ state-module layer and preserves observable search semantics while reducing per-keystroke CPU work.

Changes:

  • Extracted haystack construction into a pure helper (buildSearchHaystack) and introduced a derived searchIndex that rebuilds only when missions changes.
  • Updated filtering to use searchIndex[index].includes(query) with a trimmed/lowercased query.
  • Added/expanded unit tests to validate index reuse/rebuild behavior and assert equivalence with the previous inline algorithm across a representative query corpus.
File summaries
File Description
apps/extension/src/lib/state/feed.svelte.ts Precomputes a lowercased joined search haystack per mission and filters against a derived index to avoid per-keystroke normalization work.
apps/extension/tests/unit/state/feed.test.ts Adds a dedicated “precomputed search index” test suite, including an equivalence test against the previous algorithm.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 0
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@guyghost
guyghost merged commit 9d21b7b into develop Aug 28, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants