diff --git a/web/src/matcher.test.ts b/web/src/matcher.test.ts new file mode 100644 index 0000000..094c089 --- /dev/null +++ b/web/src/matcher.test.ts @@ -0,0 +1,201 @@ +import { describe, expect, it } from "vitest"; + +import { + matchAndRank, + type CommandCandidate, + type DirectoryCandidate, + type MatchCandidate, + type MatchResult, + type SessionCandidate, +} from "./matcher.js"; + +function session( + name: string, + directory?: string, + command?: string, + tool?: string, +): SessionCandidate { + return { kind: "session", fields: { name, directory, command, tool } }; +} + +function command(label: string): CommandCandidate { + return { kind: "command", fields: { label } }; +} + +function directory(path: string): DirectoryCandidate { + return { kind: "directory", fields: { path } }; +} + +function label(result: MatchResult): string { + switch (result.candidate.kind) { + case "session": + return `session:${result.candidate.fields.name}`; + case "command": + return `command:${result.candidate.fields.label}`; + case "directory": + return `directory:${result.candidate.fields.path}`; + } +} + +function positions(result: MatchResult): number[] { + return result.ranges.flatMap(([start, end]) => + Array.from({ length: end - start }, (_, offset) => start + offset), + ); +} + +function expectResults( + query: string, + candidates: MatchCandidate[], + expected: Array<[label: string, score: number, field?: string, positions?: number[]]>, +): void { + const results = matchAndRank(query, candidates); + + expect(results.map((result) => [label(result), result.score])).toEqual( + expected.map(([candidateLabel, score]) => [candidateLabel, score]), + ); + expect(results.map((result) => result.field)).toEqual( + expected.map(([, , field]) => field), + ); + expect(results.map(positions)).toEqual(expected.map(([, , , matched]) => matched ?? [])); +} + +describe("matchAndRank fixtures", () => { + it("F1: an empty query keeps input order", () => { + expectResults( + "", + [ + session("kill", "~/dev/api", "bash"), + session("killer-app", "~/dev/killer-app", "claude --resume", "claude"), + command("Kill selected session"), + ], + [ + ["session:kill", 0], + ["session:killer-app", 0], + ["command:Kill selected session", 0], + ], + ); + }); + + it("F2: an exact session name beats a prefix and kind breaks a tie", () => { + expectResults( + "kill", + [ + session("kill", "~/dev/api", "bash"), + session("killer-app", "~/dev/killer-app", "claude --resume", "claude"), + command("Kill selected session"), + ], + [ + ["session:kill", 178, "name", [0, 1, 2, 3]], + ["session:killer-app", 146, "name", [0, 1, 2, 3]], + ["command:Kill selected session", 146, "label", [0, 1, 2, 3]], + ], + ); + }); + + it("F3: path segment boundaries beat mid-segment matches", () => { + expectResults( + "foo", + [directory("~/dev/foobar"), directory("~/src/myfoo"), directory("~/foo")], + [ + ["directory:~/foo", 78, "path", [2, 3, 4]], + ["directory:~/dev/foobar", 74, "path", [6, 7, 8]], + ["directory:~/src/myfoo", 56, "path", [8, 9, 10]], + ], + ); + }); + + it("F4: a query matching nothing returns no candidates", () => { + expectResults("zzz", [session("kill", "~/dev/api", "bash"), command("Kill selected session")], []); + }); + + it("F5: the highest-scoring embedding can differ from the greedy embedding", () => { + expectResults( + "mtu", + [session("maestro-tui"), session("maestro-daemon"), session("mtui-scratch")], + [ + ["session:mtui-scratch", 122, "name", [0, 1, 2]], + ["session:maestro-tui", 91, "name", [0, 8, 9]], + ], + ); + }); + + it("F6: a name beats a weighted directory match", () => { + expectResults( + "api", + [session("api", "~/dev/web", "bash"), session("worker", "~/dev/api", "bash")], + [ + ["session:api", 154, "name", [0, 1, 2]], + ["session:worker", 50, "directory", [6, 7, 8]], + ], + ); + }); + + it("F7: matching is case-insensitive and field length breaks a tie", () => { + expectResults( + "api", + [session("API-Gateway"), session("apiary")], + [ + ["session:apiary", 122, "name", [0, 1, 2]], + ["session:API-Gateway", 122, "name", [0, 1, 2]], + ], + ); + }); + + it("F8: the exact tool field beats the command field", () => { + expectResults( + "claude", + [session("nameless", "~/dev/x", "claude --resume", "claude"), session("clay", "~/dev/y", "bash")], + [["session:nameless", 186, "tool", [0, 1, 2, 3, 4, 5]]], + ); + }); + + it("F9: a separator earns a word-boundary bonus", () => { + expectResults( + "ws", + [session("websearch"), session("web-search")], + [ + ["session:web-search", 71, "name", [0, 4]], + ["session:websearch", 56, "name", [0, 3]], + ], + ); + }); + + it("F10: command labels rank by boundary and initial gap", () => { + expectResults( + "se", + [command("Kill selected session"), command("Create a session"), command("Show help")], + [ + ["command:Show help", 53, "label", [0, 6]], + ["command:Kill selected session", 51, "label", [5, 6]], + ["command:Create a session", 47, "label", [9, 10]], + ], + ); + }); + + it("F11: directory matches separate on their initial gap", () => { + expectResults( + "dev", + [ + session("api", "~/dev/api", "bash"), + session("notes", "~/documents/dev-notes", "nvim"), + session("scratch", "~/srv/devops", "bash"), + ], + [ + ["session:api", 54, "directory", [2, 3, 4]], + ["session:scratch", 50, "directory", [6, 7, 8]], + ["session:notes", 44, "directory", [12, 13, 14]], + ], + ); + }); + + it("F12: an exact field beats a prefix", () => { + expectResults( + "quit", + [command("Quit Maestro"), command("Quit")], + [ + ["command:Quit", 178, "label", [0, 1, 2, 3]], + ["command:Quit Maestro", 146, "label", [0, 1, 2, 3]], + ], + ); + }); +}); diff --git a/web/src/matcher.ts b/web/src/matcher.ts new file mode 100644 index 0000000..00adb5c --- /dev/null +++ b/web/src/matcher.ts @@ -0,0 +1,224 @@ +export type CandidateKind = "session" | "command" | "directory"; + +export interface SessionCandidate { + kind: "session"; + fields: { + name: string; + directory?: string; + command?: string; + tool?: string; + }; +} + +export interface CommandCandidate { + kind: "command"; + fields: { label: string }; +} + +export interface DirectoryCandidate { + kind: "directory"; + fields: { path: string }; +} + +export type MatchCandidate = SessionCandidate | CommandCandidate | DirectoryCandidate; +export type MatchFieldName = "name" | "directory" | "command" | "tool" | "label" | "path"; +export type MatchRange = readonly [start: number, end: number]; + +export interface MatchResult { + candidate: C; + score: number; + field: MatchFieldName | undefined; + ranges: MatchRange[]; +} + +interface Field { + name: MatchFieldName; + text: string; + weight: number; +} + +interface Embedding { + score: number; + positions: number[]; +} + +interface RankedResult extends MatchResult { + matchedText: string; + inputIndex: number; +} + +const KIND_ORDER: Record = { + session: 0, + command: 1, + directory: 2, +}; + +function fields(candidate: MatchCandidate): Field[] { + switch (candidate.kind) { + case "session": { + const values: Array<[MatchFieldName, string | undefined, number]> = [ + ["name", candidate.fields.name, 0], + ["directory", candidate.fields.directory, -24], + ["command", candidate.fields.command, -32], + ["tool", candidate.fields.tool, -40], + ]; + return values.flatMap(([name, text, weight]) => + text === undefined ? [] : [{ name, text, weight }], + ); + } + case "command": + return [{ name: "label", text: candidate.fields.label, weight: 0 }]; + case "directory": + return [{ name: "path", text: candidate.fields.path, weight: 0 }]; + } +} + +function normalizeQuery(query: string): string { + return query.toLowerCase().replace(/\s/g, ""); +} + +function boundaryBonus(text: string, position: number): number { + if (position === 0) return 18; + + const previous = text[position - 1]; + if ("/\\-_ .:".includes(previous)) return 16; + + const current = text[position]; + const isUppercase = current !== current.toLowerCase() && current === current.toUpperCase(); + const previousIsLowercase = + previous !== previous.toUpperCase() && previous === previous.toLowerCase(); + if (isUppercase && previousIsLowercase) return 12; + + const isDigit = current >= "0" && current <= "9"; + const previousIsDigit = previous >= "0" && previous <= "9"; + if (isDigit && !previousIsDigit) return 12; + + return 0; +} + +function contribution(text: string, position: number, previousPosition: number): number { + const gap = position - previousPosition - 1; + return 16 + boundaryBonus(text, position) + (gap === 0 ? 8 : -Math.min(gap, 12)); +} + +function isBetterEmbedding(candidate: Embedding, incumbent: Embedding | undefined): boolean { + if (!incumbent || candidate.score !== incumbent.score) { + return !incumbent || candidate.score > incumbent.score; + } + for (let index = 0; index < candidate.positions.length; index++) { + if (candidate.positions[index] !== incumbent.positions[index]) { + return candidate.positions[index] < incumbent.positions[index]; + } + } + return false; +} + +function bestEmbedding(query: string, text: string): Embedding | undefined { + const lowerText = text.toLowerCase(); + let previousStates = new Map(); + + for (let queryIndex = 0; queryIndex < query.length; queryIndex++) { + const states = new Map(); + for (let position = 0; position < lowerText.length; position++) { + if (lowerText[position] !== query[queryIndex]) continue; + + if (queryIndex === 0) { + states.set(position, { + score: contribution(text, position, -1), + positions: [position], + }); + continue; + } + + for (const [previousPosition, previous] of previousStates) { + if (previousPosition >= position) continue; + const candidate = { + score: previous.score + contribution(text, position, previousPosition), + positions: [...previous.positions, position], + }; + if (isBetterEmbedding(candidate, states.get(position))) states.set(position, candidate); + } + } + if (states.size === 0) return undefined; + previousStates = states; + } + + let best: Embedding | undefined; + for (const embedding of previousStates.values()) { + if (isBetterEmbedding(embedding, best)) best = embedding; + } + return best; +} + +function toRanges(positions: number[]): MatchRange[] { + const ranges: MatchRange[] = []; + for (const position of positions) { + const previous = ranges[ranges.length - 1]; + if (previous && previous[1] === position) { + ranges[ranges.length - 1] = [previous[0], position + 1]; + } else { + ranges.push([position, position + 1]); + } + } + return ranges; +} + +function compareByCodePoint(left: string, right: string): number { + const leftPoints = Array.from(left, (character) => character.codePointAt(0)!); + const rightPoints = Array.from(right, (character) => character.codePointAt(0)!); + const length = Math.min(leftPoints.length, rightPoints.length); + for (let index = 0; index < length; index++) { + if (leftPoints[index] !== rightPoints[index]) return leftPoints[index] - rightPoints[index]; + } + return leftPoints.length - rightPoints.length; +} + +export function matchAndRank( + query: string, + candidates: readonly C[], +): MatchResult[] { + const normalizedQuery = normalizeQuery(query); + if (normalizedQuery === "") { + return candidates.map((candidate) => ({ candidate, score: 0, field: undefined, ranges: [] })); + } + + const results: RankedResult[] = []; + candidates.forEach((candidate, inputIndex) => { + let best: + | { field: Field; score: number; positions: number[] } + | undefined; + + for (const field of fields(candidate)) { + const embedding = bestEmbedding(normalizedQuery, field.text); + if (!embedding) continue; + + const compactText = field.text.toLowerCase().replace(/\s/g, ""); + const adjustment = + compactText === normalizedQuery ? 64 : compactText.startsWith(normalizedQuery) ? 32 : 0; + const score = embedding.score + adjustment + field.weight; + if (!best || score > best.score) best = { field, score, positions: embedding.positions }; + } + + if (best) { + results.push({ + candidate, + score: best.score, + field: best.field.name, + ranges: toRanges(best.positions), + matchedText: best.field.text, + inputIndex, + }); + } + }); + + results.sort( + (left, right) => + right.score - left.score || + KIND_ORDER[left.candidate.kind] - KIND_ORDER[right.candidate.kind] || + left.matchedText.length - right.matchedText.length || + compareByCodePoint(left.matchedText, right.matchedText) || + left.inputIndex - right.inputIndex, + ); + + return results.map(({ candidate, score, field, ranges }) => ({ candidate, score, field, ranges })); +}