diff --git a/scripts/test-root-search-perf.mjs b/scripts/test-root-search-perf.mjs new file mode 100644 index 00000000..22fd6798 --- /dev/null +++ b/scripts/test-root-search-perf.mjs @@ -0,0 +1,375 @@ +#!/usr/bin/env node + +import assert from 'assert/strict'; +import fs from 'fs'; +import path from 'path'; +import { createRequire } from 'module'; +import { performance } from 'perf_hooks'; +import vm from 'vm'; + +const require = createRequire(import.meta.url); +const ts = require('typescript'); + +const moduleCache = new Map(); +const ROOT = process.cwd(); + +const reactStub = { + createElement: (type, props, ...children) => ({ type, props: props || {}, children }), + Fragment: 'Fragment', +}; + +function makeComponent(name) { + return function StubComponent(props) { + return { type: name, props: props || {}, children: [] }; + }; +} + +const lucideStub = new Proxy({ __esModule: true }, { + get(target, prop) { + if (prop === '__esModule') return true; + if (prop === 'default') return target; + return makeComponent(String(prop)); + }, +}); + +function getStubbedModule(request) { + if (request === 'react') return { __esModule: true, default: reactStub, ...reactStub }; + if (request === 'lucide-react') return lucideStub; + if (request === './quicklink-icons') { + return { renderQuickLinkIconGlyph: () => null }; + } + if (request.startsWith('../icons/')) { + return { __esModule: true, default: makeComponent(path.basename(request)) }; + } + return null; +} + +function loadTsModule(filePath) { + const resolvedPath = path.resolve(ROOT, filePath); + if (moduleCache.has(resolvedPath)) return moduleCache.get(resolvedPath).exports; + + const source = fs.readFileSync(resolvedPath, 'utf8'); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.React, + esModuleInterop: true, + importsNotUsedAsValues: ts.ImportsNotUsedAsValues.Remove, + }, + fileName: resolvedPath, + }); + + const module = { exports: {} }; + moduleCache.set(resolvedPath, module); + + const localRequire = (request) => { + const stubbed = getStubbedModule(request); + if (stubbed) return stubbed; + + if (request.startsWith('.')) { + const candidate = path.resolve(path.dirname(resolvedPath), request); + for (const suffix of ['', '.ts', '.tsx', '.js', '.jsx', '.json', '/index.ts', '/index.tsx']) { + const nextPath = `${candidate}${suffix}`; + if (!fs.existsSync(nextPath) || !fs.statSync(nextPath).isFile()) continue; + if (nextPath.endsWith('.svg')) return ''; + if (nextPath.endsWith('.ts') || nextPath.endsWith('.tsx')) return loadTsModule(nextPath); + return require(nextPath); + } + } + + return require(request); + }; + + const sandbox = { + module, + exports: module.exports, + require: localRequire, + console, + URL, + Date, + Math, + String, + Number, + Set, + Map, + Object, + Array, + RegExp, + }; + + vm.runInNewContext(transpiled.outputText, sandbox, { filename: resolvedPath }); + return module.exports; +} + +const commandHelpers = loadTsModule('src/renderer/src/utils/command-helpers.tsx'); +const ranking = loadTsModule('src/renderer/src/utils/root-search-ranking.ts'); + +const { createRootCommandScoreIndex, filterCommands, rankCommands, rankCommandsWithIndex } = commandHelpers; +const { scoreRootSearchFields } = ranking; + +const COMMAND_COUNT = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_COMMANDS || 6000); +const ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_ITERATIONS || 6); +const WARMUP_ITERATIONS = Number(process.env.SUPERCMD_ROOT_SEARCH_PERF_WARMUPS || 1); + +const QUERIES = [ + 'notes', + 'clip', + 'window', + 'project alpha', + 'deploy', + 'timer', + 'gh', + 'cmd 42', +]; + +const WORDS = [ + 'Notes', + 'Clipboard', + 'Window', + 'Browser', + 'Settings', + 'Canvas', + 'Timer', + 'Schedule', + 'Project', + 'Deploy', + 'Debug', + 'GitHub', + 'Snippet', + 'Search', + 'Memory', + 'Automation', +]; + +function normalizeSearchText(value) { + return String(value || '') + .normalize('NFKD') + .toLowerCase() + .replace(/[^\p{L}\p{N}]+/gu, ' ') + .trim(); +} + +function makeCommands(count) { + const commands = []; + const aliases = {}; + + for (let i = 0; i < count; i += 1) { + const primary = WORDS[i % WORDS.length]; + const secondary = WORDS[(i * 7 + 3) % WORDS.length]; + const group = i % 4 === 0 ? 'extension' : i % 4 === 1 ? 'script' : i % 4 === 2 ? 'app' : 'system'; + const command = { + id: `synthetic-command-${i}`, + title: `${primary} ${secondary} Command ${i}`, + subtitle: `${secondary} tools for Project Alpha workspace ${i % 97}`, + category: group, + path: group === 'extension' ? `extension-${i % 80}/command-${i}` : undefined, + keywords: [ + primary, + secondary, + `cmd ${i}`, + i % 9 === 0 ? 'project alpha' : 'workspace', + i % 11 === 0 ? 'gh github repo' : 'local action', + ], + alwaysOnTop: i % 997 === 0, + }; + commands.push(command); + + if (i % 5 === 0) aliases[command.id] = `${primary.toLowerCase()}-${i}`; + if (i % 37 === 0) aliases[command.id] = 'gh'; + } + + return { commands, aliases }; +} + +function rootCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.08 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.68 })), + ]; +} + +function legacyRankCommandFields(command, alias) { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.06 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description', weight: 0.7 })), + ]; +} + +function legacyRootCommandMatches(commands, query, aliases) { + const normalizedQuery = normalizeSearchText(query); + const ranked = commands + .map((command) => { + const alias = aliases[command.id] || ''; + const firstPass = scoreRootSearchFields(query, legacyRankCommandFields(command, alias)); + if (!firstPass.matched) return null; + return { + command, + score: firstPass.matchScore, + hasExactAliasMatch: Boolean(alias) && normalizeSearchText(alias) === normalizedQuery, + }; + }) + .filter(Boolean) + .sort((a, b) => { + if (a.hasExactAliasMatch !== b.hasExactAliasMatch) { + return Number(b.hasExactAliasMatch) - Number(a.hasExactAliasMatch); + } + if (a.command.alwaysOnTop !== b.command.alwaysOnTop) return a.command.alwaysOnTop ? -1 : 1; + if (b.score !== a.score) return b.score - a.score; + return a.command.title.localeCompare(b.command.title); + }); + + return ranked + .map(({ command }) => { + const scored = scoreRootSearchFields(query, rootCommandFields(command, aliases[command.id] || '')); + assert.equal(scored.matched, true, `${command.id} lost its second-pass match for ${query}`); + return { + id: command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function optimizedRootCommandMatches(commands, query, aliases) { + return rankCommands(commands, query, aliases) + .map((entry) => { + if (typeof entry.matchKind === 'string' && typeof entry.matchScore === 'number') { + return { + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + }; + } + + const scored = scoreRootSearchFields(query, rootCommandFields(entry.command, aliases[entry.command.id] || '')); + assert.equal(scored.matched, true, `${entry.command.id} lost its optimized fallback match for ${query}`); + return { + id: entry.command.id, + matchKind: scored.matchKind, + matchScore: scored.matchScore, + }; + }); +} + +function indexedRootCommandMatches(index, query) { + return rankCommandsWithIndex(index, query) + .map((entry) => ({ + id: entry.command.id, + matchKind: entry.matchKind, + matchScore: entry.matchScore, + })); +} + +function signature(matches) { + return matches + .map((match) => `${match.id}:${match.matchKind}:${match.matchScore}`) + .sort(); +} + +function assertSameRootCommandMatches(commands, aliases) { + const index = createRootCommandScoreIndex(commands, aliases); + for (const query of QUERIES) { + const legacySignature = signature(legacyRootCommandMatches(commands, query, aliases)); + assert.deepEqual( + signature(optimizedRootCommandMatches(commands, query, aliases)), + legacySignature, + `root command matches changed for query "${query}"` + ); + assert.deepEqual( + signature(indexedRootCommandMatches(index, query)), + legacySignature, + `indexed root command matches changed for query "${query}"` + ); + } +} + +function timeRun(fn) { + const start = performance.now(); + let total = 0; + for (const query of QUERIES) { + total += fn(query); + } + return { duration: performance.now() - start, total }; +} + +function measure(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) timeRun(fn); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const result = timeRun(fn); + samples.push(result.duration); + total = result.total; + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +function measureSingle(label, fn) { + for (let i = 0; i < WARMUP_ITERATIONS; i += 1) fn(); + + const samples = []; + let total = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const start = performance.now(); + total = fn(); + samples.push(performance.now() - start); + } + + samples.sort((a, b) => a - b); + const median = samples[Math.floor(samples.length / 2)]; + const min = samples[0]; + const max = samples[samples.length - 1]; + + return { label, median, min, max, total }; +} + +const { commands, aliases } = makeCommands(COMMAND_COUNT); + +assertSameRootCommandMatches(commands, aliases); +const commandScoreIndex = createRootCommandScoreIndex(commands, aliases); + +const legacy = measure('legacy filter + two-pass command scoring', (query) => { + const filtered = filterCommands(commands, query, aliases); + const matches = legacyRootCommandMatches(commands, query, aliases); + return filtered.length + matches.length; +}); + +const optimized = measure('optimized command scoring path', (query) => { + const matches = optimizedRootCommandMatches(commands, query, aliases); + return matches.length; +}); + +const indexed = measure('indexed command scoring path', (query) => { + const matches = indexedRootCommandMatches(commandScoreIndex, query); + return matches.length; +}); + +const compile = measureSingle('command scoring index compile', () => { + return createRootCommandScoreIndex(commands, aliases).entries.length; +}); + +const optimizedSpeedup = legacy.median > 0 ? legacy.median / optimized.median : 0; +const indexedSpeedup = legacy.median > 0 ? legacy.median / indexed.median : 0; +const indexedVsOptimizedSpeedup = optimized.median > 0 ? optimized.median / indexed.median : 0; + +console.log('Root search perf harness'); +console.log(`commands=${COMMAND_COUNT} queries=${QUERIES.length} iterations=${ITERATIONS} warmups=${WARMUP_ITERATIONS}`); +console.log(`${legacy.label}: median=${legacy.median.toFixed(2)}ms min=${legacy.min.toFixed(2)}ms max=${legacy.max.toFixed(2)}ms total=${legacy.total}`); +console.log(`${optimized.label}: median=${optimized.median.toFixed(2)}ms min=${optimized.min.toFixed(2)}ms max=${optimized.max.toFixed(2)}ms total=${optimized.total}`); +console.log(`${indexed.label}: median=${indexed.median.toFixed(2)}ms min=${indexed.min.toFixed(2)}ms max=${indexed.max.toFixed(2)}ms total=${indexed.total}`); +console.log(`${compile.label}: median=${compile.median.toFixed(2)}ms min=${compile.min.toFixed(2)}ms max=${compile.max.toFixed(2)}ms total=${compile.total}`); +console.log(`legacy-vs-optimized-speedup=${optimizedSpeedup.toFixed(2)}x`); +console.log(`legacy-vs-indexed-speedup=${indexedSpeedup.toFixed(2)}x`); +console.log(`optimized-vs-indexed-speedup=${indexedVsOptimizedSpeedup.toFixed(2)}x`); diff --git a/src/renderer/src/hooks/useLauncherCommandModel.ts b/src/renderer/src/hooks/useLauncherCommandModel.ts index 44ca0fbc..65a3a8ae 100644 --- a/src/renderer/src/hooks/useLauncherCommandModel.ts +++ b/src/renderer/src/hooks/useLauncherCommandModel.ts @@ -9,7 +9,11 @@ import type { import type { BrowserSearchResult, useBrowserSearch } from './useBrowserSearch'; import type { CalcResult } from '../smart-calculator'; import { tryCalculate, tryCalculateAsync } from '../smart-calculator'; -import { filterCommands, rankCommands } from '../utils/command-helpers'; +import { + createRootCommandScoreIndex, + filterCommands, + rankCommandsWithIndex, +} from '../utils/command-helpers'; import { asTildePath, buildFileResultCommandId, @@ -356,6 +360,20 @@ export function useLauncherCommandModel({ [] ); const hasSearchQuery = searchQuery.trim().length > 0; + const shouldIndexRootCommands = hasSearchQuery && !aiMode && rootBangState.mode === 'none'; + const rootSearchableCommands = useMemo( + () => shouldIndexRootCommands + ? contextualCommands.filter((cmd) => + cmd.id !== WEB_SEARCH_COMMAND_ID && + (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) + ) + : [], + [contextualCommands, hiddenListOnlyCommandIds, hasSearchQuery, shouldIndexRootCommands] + ); + const rootCommandScoreIndex = useMemo( + () => createRootCommandScoreIndex(rootSearchableCommands, commandAliases), + [rootSearchableCommands, commandAliases] + ); const visibleSourceCommands = useMemo( () => sourceCommands .filter((cmd) => !hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) @@ -521,21 +539,9 @@ export function useLauncherCommandModel({ const browserSearchResultCommands = useMemo(() => [], []); const commandCandidates = useMemo(() => { - if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; - const searchableCommands = contextualCommands.filter((cmd) => - cmd.id !== WEB_SEARCH_COMMAND_ID && - (!hiddenListOnlyCommandIds.has(cmd.id) || hasSearchQuery) - ); - return rankCommands(searchableCommands, searchQuery, commandAliases) - .map(({ command }) => { - const alias = commandAliases[command.id] || ''; - const scored = scoreRootSearchFields(searchQuery, [ - { value: command.title, kind: 'label', weight: 1 }, - { value: alias, kind: 'alias', weight: 1.08 }, - { value: command.subtitle, kind: 'description', weight: 0.74 }, - ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.68 })), - ]); - if (!scored.matched) return null; + if (!shouldIndexRootCommands) return []; + return rankCommandsWithIndex(rootCommandScoreIndex, searchQuery) + .map(({ command, matchKind, matchScore }) => { const subtype = inferCommandSubtype(command); const stableKey = `command:${command.id}`; return scoreRootSearchCandidate({ @@ -551,8 +557,8 @@ export function useLauncherCommandModel({ label: command.title, description: command.subtitle, pathOrUrl: command.path, - matchKind: scored.matchKind, - matchScore: scored.matchScore, + matchKind, + matchScore, sourceQualityBoost: command.alwaysOnTop ? 80 : 0, freshnessBoost: 0, pathLocationBoost: 0, @@ -561,7 +567,7 @@ export function useLauncherCommandModel({ }, searchQuery, rootSearchRanking); }) .filter((candidate): candidate is RootSearchCandidate => Boolean(candidate)); - }, [hasSearchQuery, aiMode, rootBangState, contextualCommands, hiddenListOnlyCommandIds, searchQuery, commandAliases, rootSearchRanking]); + }, [shouldIndexRootCommands, rootCommandScoreIndex, searchQuery, rootSearchRanking]); const fileCandidates = useMemo(() => { if (!hasSearchQuery || aiMode || rootBangState.mode !== 'none') return []; diff --git a/src/renderer/src/utils/command-helpers.tsx b/src/renderer/src/utils/command-helpers.tsx index dfa5bcbc..c59a2fba 100644 --- a/src/renderer/src/utils/command-helpers.tsx +++ b/src/renderer/src/utils/command-helpers.tsx @@ -27,7 +27,14 @@ import IconPen from '../icons/Pen'; import IconMagicWand from '../icons/MagicWand'; import { formatShortcutForDisplay } from './hyper-key'; import { renderQuickLinkIconGlyph } from './quicklink-icons'; -import { scoreRootSearchFields } from './root-search-ranking'; +import { + precompileRootSearchQuery, + precompileRootSearchScoringFields, + scorePrecompiledRootSearchFields, + type MatchKind, + type PrecompiledRootSearchScoringField, + type RootSearchScoringField, +} from './root-search-ranking'; import { getTranslitVariant } from './transliterate'; export interface LauncherAction { @@ -194,6 +201,53 @@ export type RankedCommand = { score: number; }; +export type IndexedRankedCommand = RankedCommand & { + matchKind: MatchKind; + matchScore: number; +}; + +export type RootCommandScoreIndexEntry = { + command: CommandInfo; + rankingFields: PrecompiledRootSearchScoringField[]; + scoringFields: PrecompiledRootSearchScoringField[]; + normalizedAlias: string; +}; + +export type RootCommandScoreIndex = { + entries: RootCommandScoreIndexEntry[]; +}; + +function getRootSearchCommandRankingFields(command: CommandInfo, alias: string): RootSearchScoringField[] { + return [ + { value: command.title, kind: 'label', weight: 1 }, + { value: alias, kind: 'alias', weight: 1.06 }, + { value: command.subtitle, kind: 'description', weight: 0.74 }, + ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.7 })), + ]; +} + +export function createRootCommandScoreIndex( + commands: CommandInfo[], + aliasLookup: Record = {} +): RootCommandScoreIndex { + return { + entries: commands.map((command) => { + const alias = aliasLookup[command.id] || ''; + const rankingFields = precompileRootSearchScoringFields(getRootSearchCommandRankingFields(command, alias)); + const scoringFields = rankingFields.map((field, index) => { + const scoringWeight = index === 1 ? 1.08 : index >= 3 ? 0.68 : field.weight; + return scoringWeight === field.weight ? field : { ...field, weight: scoringWeight }; + }); + return { + command, + rankingFields, + scoringFields, + normalizedAlias: normalizeSearchText(alias), + }; + }), + }; +} + function bestTermScore(term: string, candidates: SearchCandidate[]): number { let best = 0; for (const candidate of candidates) { @@ -339,37 +393,60 @@ export function filterCommands( return [...matchedTop, ...matchedRest]; } -export function rankCommands( - commands: CommandInfo[], - query: string, - aliasLookup: Record = {} -): RankedCommand[] { +type RankedCommandSortEntry = IndexedRankedCommand & { + hasExactAliasMatch: boolean; +}; + +export function rankCommandsWithIndex(index: RootCommandScoreIndex, query: string): IndexedRankedCommand[] { const normalizedQuery = normalizeSearchText(query); if (!normalizedQuery) { - return commands.map((command) => ({ command, score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 0 })); - } - - return commands - .map((command): RankedCommand | null => { - const alias = aliasLookup[command.id] || ''; - const scored = scoreRootSearchFields(query, [ - { value: command.title, kind: 'label', weight: 1 }, - { value: alias, kind: 'alias', weight: 1.06 }, - { value: command.subtitle, kind: 'description', weight: 0.74 }, - ...(command.keywords || []).map((keyword) => ({ value: keyword, kind: 'description' as const, weight: 0.7 })), - ]); - if (!scored.matched) return null; - return { command, score: scored.matchScore }; + return index.entries.map(({ command }) => ({ + command, + score: command.alwaysOnTop ? Number.MAX_SAFE_INTEGER : 0, + matchKind: 'exact', + matchScore: 0, + })); + } + + const compiledQuery = precompileRootSearchQuery(query); + + return index.entries + .map((entry): RankedCommandSortEntry | null => { + const ranked = scorePrecompiledRootSearchFields(compiledQuery, entry.rankingFields); + if (!ranked.matched) return null; + const scored = scorePrecompiledRootSearchFields(compiledQuery, entry.scoringFields); + return { + command: entry.command, + score: ranked.matchScore, + matchKind: scored.matched ? scored.matchKind : ranked.matchKind, + matchScore: scored.matched ? scored.matchScore : ranked.matchScore, + hasExactAliasMatch: entry.normalizedAlias === normalizedQuery, + }; }) - .filter((entry): entry is RankedCommand => entry !== null) + .filter((entry): entry is RankedCommandSortEntry => entry !== null) .sort((a, b) => { - const aAlias = normalizeSearchText(aliasLookup[a.command.id] || '') === normalizedQuery; - const bAlias = normalizeSearchText(aliasLookup[b.command.id] || '') === normalizedQuery; - if (aAlias !== bAlias) return Number(bAlias) - Number(aAlias); + if (a.hasExactAliasMatch !== b.hasExactAliasMatch) { + return Number(b.hasExactAliasMatch) - Number(a.hasExactAliasMatch); + } if (a.command.alwaysOnTop !== b.command.alwaysOnTop) return a.command.alwaysOnTop ? -1 : 1; if (b.score !== a.score) return b.score - a.score; return a.command.title.localeCompare(b.command.title); - }); + }) + .map(({ command, score, matchKind, matchScore }) => ({ + command, + score, + matchKind, + matchScore, + })); +} + +export function rankCommands( + commands: CommandInfo[], + query: string, + aliasLookup: Record = {} +): RankedCommand[] { + return rankCommandsWithIndex(createRootCommandScoreIndex(commands, aliasLookup), query) + .map(({ command, score }) => ({ command, score })); } /** diff --git a/src/renderer/src/utils/root-search-ranking.ts b/src/renderer/src/utils/root-search-ranking.ts index 6eec2570..870ec88b 100644 --- a/src/renderer/src/utils/root-search-ranking.ts +++ b/src/renderer/src/utils/root-search-ranking.ts @@ -90,6 +90,28 @@ export type RootSearchScoreResult = { matchScore: number; }; +export type PrecompiledRootSearchQueryTerm = { + normalized: string; + compact: string; +}; + +export type PrecompiledRootSearchQuery = { + fullQuery: string; + terms: PrecompiledRootSearchQueryTerm[]; +}; + +export type PrecompiledRootSearchScoringField = { + raw: string; + normalized: string; + compact: string; + tokens: string[]; + boundaryInitials: string; + kind: RootSearchFieldKind; + weight: number; + isSecondaryField: boolean; + secondaryKind: MatchKind; +}; + const SEARCH_SEPARATOR_REGEX = /[^\p{L}\p{N}]+/gu; const COMBINING_MARK_REGEX = /\p{M}/gu; const DAY = 24 * 60 * 60 * 1000; @@ -147,9 +169,13 @@ export function compactRootSearchText(value: string): string { return normalizeRootSearchText(value).replace(SEARCH_SEPARATOR_REGEX, '').replace(/\s+/g, ''); } +function tokenizeNormalizedRootSearchText(normalized: string): string[] { + return normalized ? normalized.split(/\s+/).filter(Boolean) : []; +} + export function tokenizeRootSearchQuery(value: string): string[] { const normalized = normalizeRootSearchText(value); - return normalized ? normalized.split(/\s+/).filter(Boolean) : []; + return tokenizeNormalizedRootSearchText(normalized); } export function normalizeRootSearchStableValue(value: string): string { @@ -183,86 +209,117 @@ function isSubsequenceMatch(needle: string, haystack: string): boolean { return needleIndex === needle.length; } -function hasBoundaryFuzzyMatch(term: string, value: string): boolean { - if (!term || !value) return false; - const normalized = normalizeRootSearchText(value); - if (normalized.split(/\s+/).some((token) => token.startsWith(term))) return true; - const compactTerm = compactRootSearchText(term); +function getRootSearchBoundaryInitials(value: string): string { const boundaryChars = String(value || '').match(/[A-Z]?[a-z]+|[A-Z]+(?![a-z])|\d+/g) || []; const camelInitials = boundaryChars.map((part) => part[0]).join(''); - return Boolean(compactTerm.length >= 2 && compactRootSearchText(camelInitials).startsWith(compactTerm)); + return compactRootSearchText(camelInitials); +} + +export function precompileRootSearchQuery(query: string): PrecompiledRootSearchQuery { + const fullQuery = normalizeRootSearchText(query); + return { + fullQuery, + terms: tokenizeNormalizedRootSearchText(fullQuery).map((term) => ({ + normalized: normalizeRootSearchText(term), + compact: compactRootSearchText(term), + })), + }; } -function scoreSingleField(term: string, fullQuery: string, field: RootSearchScoringField): RootSearchScoreResult { +function getRootSearchFieldWeight(field: RootSearchScoringField): number { + return field.weight ?? (field.kind === 'label' ? 1 : field.kind === 'alias' || field.kind === 'nickname' ? 1.04 : 0.72); +} + +function getSecondaryRootSearchMatchKind(field: RootSearchScoringField): MatchKind { + return field.kind === 'url' ? 'url' : field.kind === 'path' ? 'path' : 'description'; +} + +export function precompileRootSearchScoringField(field: RootSearchScoringField): PrecompiledRootSearchScoringField { const raw = String(field.value || ''); const normalized = normalizeRootSearchText(raw); - if (!normalized) return { matched: false, matchKind: 'subsequence', matchScore: 0 }; - - const normalizedTerm = normalizeRootSearchText(term); - const compactField = compactRootSearchText(raw); - const compactTerm = compactRootSearchText(term); - const tokens = normalized.split(/\s+/).filter(Boolean); const isSecondaryField = field.kind === 'description' || field.kind === 'path' || field.kind === 'url'; - const secondaryKind: MatchKind = field.kind === 'url' ? 'url' : field.kind === 'path' ? 'path' : 'description'; - const weight = field.weight ?? (field.kind === 'label' ? 1 : field.kind === 'alias' || field.kind === 'nickname' ? 1.04 : 0.72); + return { + raw, + normalized, + compact: normalized ? compactRootSearchText(raw) : '', + tokens: tokenizeNormalizedRootSearchText(normalized), + boundaryInitials: raw ? getRootSearchBoundaryInitials(raw) : '', + kind: field.kind, + weight: getRootSearchFieldWeight(field), + isSecondaryField, + secondaryKind: getSecondaryRootSearchMatchKind(field), + }; +} + +export function precompileRootSearchScoringFields(fields: RootSearchScoringField[]): PrecompiledRootSearchScoringField[] { + return fields.map(precompileRootSearchScoringField); +} + +function scorePrecompiledSingleField( + term: PrecompiledRootSearchQueryTerm, + fullQuery: string, + field: PrecompiledRootSearchScoringField +): RootSearchScoreResult { + if (!field.normalized) return { matched: false, matchKind: 'subsequence', matchScore: 0 }; let kind: MatchKind | null = null; let baseScore = 0; - if (normalized === fullQuery || normalized === normalizedTerm) { + if (field.normalized === fullQuery || field.normalized === term.normalized) { kind = field.kind === 'alias' ? 'alias-exact' : field.kind === 'nickname' ? 'nickname-exact' : 'exact'; baseScore = 1000; - } else if (normalized.startsWith(normalizedTerm)) { + } else if (field.normalized.startsWith(term.normalized)) { kind = 'prefix'; baseScore = 900; - } else if (tokens.some((token) => token.startsWith(normalizedTerm))) { + } else if (field.tokens.some((token) => token.startsWith(term.normalized))) { kind = 'token-prefix'; baseScore = 780; - } else if (compactTerm && compactField.startsWith(compactTerm)) { + } else if (term.compact && field.compact.startsWith(term.compact)) { kind = 'compact-prefix'; baseScore = 740; - } else if (hasBoundaryFuzzyMatch(normalizedTerm, raw)) { + } else if (term.compact.length >= 2 && field.boundaryInitials.startsWith(term.compact)) { kind = 'word-boundary-fuzzy'; - const compactness = Math.max(0, 1 - Math.max(0, compactField.length - compactTerm.length) / 24); + const compactness = Math.max(0, 1 - Math.max(0, field.compact.length - term.compact.length) / 24); baseScore = 620 + Math.round(compactness * 110); - } else if (normalizedTerm.length >= 2 && normalized.includes(normalizedTerm)) { + } else if (term.normalized.length >= 2 && field.normalized.includes(term.normalized)) { kind = 'contains'; baseScore = 560; - } else if (compactTerm.length >= 2 && isSubsequenceMatch(compactTerm, compactField)) { + } else if (term.compact.length >= 2 && isSubsequenceMatch(term.compact, field.compact)) { kind = 'subsequence'; - const density = compactTerm.length / Math.max(compactField.length, compactTerm.length); + const density = term.compact.length / Math.max(field.compact.length, term.compact.length); baseScore = 380 + Math.round(Math.min(140, density * 170)); } if (!kind || baseScore <= 0) return { matched: false, matchKind: 'subsequence', matchScore: 0 }; - if (isSecondaryField && baseScore < 900) { - kind = secondaryKind; + if (field.isSecondaryField && baseScore < 900) { + kind = field.secondaryKind; baseScore = Math.min(500, Math.max(260, Math.round(baseScore * 0.72))); } const compactnessBoost = kind === 'exact' || kind === 'alias-exact' || kind === 'nickname-exact' || kind === 'prefix' - ? Math.max(0, 36 - Math.max(0, compactField.length - compactTerm.length) * 2) + ? Math.max(0, 36 - Math.max(0, field.compact.length - term.compact.length) * 2) : 0; - const weightedScore = Math.round((baseScore + compactnessBoost) * weight); + const weightedScore = Math.round((baseScore + compactnessBoost) * field.weight); return { matched: true, matchKind: kind, matchScore: weightedScore }; } -export function scoreRootSearchFields(query: string, fields: RootSearchScoringField[]): RootSearchScoreResult { - const fullQuery = normalizeRootSearchText(query); - const queryTerms = tokenizeRootSearchQuery(query); - if (!fullQuery || queryTerms.length === 0) { +export function scorePrecompiledRootSearchFields( + query: PrecompiledRootSearchQuery, + fields: PrecompiledRootSearchScoringField[] +): RootSearchScoreResult { + if (!query.fullQuery || query.terms.length === 0) { return { matched: false, matchKind: 'subsequence', matchScore: 0 }; } let total = 0; let bestKind: MatchKind = 'subsequence'; - for (const term of queryTerms) { + for (const term of query.terms) { let bestForTerm: RootSearchScoreResult | null = null; for (const field of fields) { - const scored = scoreSingleField(term, fullQuery, field); + const scored = scorePrecompiledSingleField(term, query.fullQuery, field); if (!scored.matched) continue; if (!bestForTerm || scored.matchScore > bestForTerm.matchScore) { bestForTerm = scored; @@ -280,10 +337,17 @@ export function scoreRootSearchFields(query: string, fields: RootSearchScoringFi return { matched: true, matchKind: bestKind, - matchScore: Math.round(total / queryTerms.length), + matchScore: Math.round(total / query.terms.length), }; } +export function scoreRootSearchFields(query: string, fields: RootSearchScoringField[]): RootSearchScoreResult { + return scorePrecompiledRootSearchFields( + precompileRootSearchQuery(query), + precompileRootSearchScoringFields(fields) + ); +} + function getFrecencyBoost(stableKey: string, ranking: RootSearchRankingState | undefined, now: number): number { const entry = ranking?.[stableKey]; if (!entry) return 0;