TL;DR
createSearcher 멀티필드 모드에서 chosung: false 필드가 하나라도 있으면 세션 최적화(이전 매치만 재스캔)가 unsound하다. IME 순방향 타이핑처럼 한 searcher에 쿼리를 점점 늘려 호출하면, 제목/노트 등 chosung:false 필드로만 매치되는 항목이 실종된다. fresh searcher로는 찾아진다. createSearcher 없이 재현되지 않으므로 세션 로직 버그다.
재현 (test/에 drop-in, casely 무관)
import { describe, expect, it } from "vitest";
import { createSearcher } from "../src/index";
describe("session reuse unsound with per-field chosung:false", () => {
const items = [
{ id: 1, title: "판결이 동일한", name: "아무개" }, // "판/판결"엔 title(chosung:false)로만 관련
{ id: 2, title: "무관한 제목", name: "표범수" }, // name이 ㅍ으로 시작
];
const opts = {
fields: [
{ key: (i: (typeof items)[number]) => i.title, chosung: false },
{ key: (i: (typeof items)[number]) => i.name, chosung: true },
],
whitespace: "split" as const,
};
it("forward typing drops a title-only match that a fresh search finds", () => {
const s = createSearcher(items, opts);
const forward = ["ㅍ", "파", "판", "판결"].map((q) => s.search(q).map((r) => r.item.id));
const fresh = createSearcher(items, opts).search("판결").map((r) => r.item.id);
expect(fresh).toEqual([1]); // fresh는 찾음
expect(forward.at(-1)).toContain(1); // FAILS: 세션이 잃어버림
});
});
관측:
forward: [ ㅍ:[2], 파:[], 판:[], 판결:[] ]
fresh 판결: [1]
근본 원인
두 기능의 상호작용:
-
세션 재사용 판정 — src/createSearcher.ts, makeRuntime 내부(대략 line 147-155):
// 이전 모든 토큰이 각각 어떤 현재 토큰의 atom-prefix 이면 매치 집합은 단조 축소 →
const canReuse =
prevTokens.length > 0 &&
prevTokens.every((p) => p.length > 0 && currentTokens.some((c) => c.startsWith(p))) &&
flagsCompatible &&
prevMatchedIndices !== null;
"atom-prefix 확장 = 매치 집합 단조 축소"를 가정하고 prevMatchedIndices만 재스캔한다.
-
chosung 하드 필터 — src/matchFields.ts:66:
if (chosung && fields[i].chosung === false) continue; // 초성-only 토큰은 chosung:false 필드 스킵
충돌: 초성-only 토큰 "ㅍ"(atoms=자음 하나)은 "파"(atoms=자음+모음)의 atom-prefix다 → canReuse=true. 그러나 "ㅍ"은 초성-only라 title(chosung:false)에서 제외됐고, "파"는 초성-only가 아니라 제외되지 않는다. 따라서 title로만 매치되는 항목(id1)은 "ㅍ" 매치엔 없다가 "파" 매치엔 생긴다 → 매치 집합이 커진다(단조 축소 위반). prevMatchedIndices(id1 없음)만 재스캔하니 id1은 세션이 리셋될 때까지(쿼리가 짧아져 prefix 규칙이 깨질 때까지) 영구 실종.
즉 "토큰의 초성-only 여부"는 atom-prefix에 대해 단조가 아니다 (모음 atom 하나가 붙으면 초성-only=true→false로 뒤집혀 chosung:false 필드가 un-gate됨). 세션 판정은 이 전이를 무효화하지 않는다.
chosung:false 필드가 없으면(모두 chosung:true 또는 단일 필드) 단조성이 성립해 문제없다 — 그래서 이름 필드로 매치되는 항목은 멀쩡히 나온다.
수정안
세션 재사용 가드를 chosung-aware로. chosung:false 필드가 있는 멀티필드 searcher에서만 적용.
- 정밀안(권장): 이전 쿼리의 토큰별 "초성-only" 플래그를
prevTokens와 함께 저장. 재사용 조건에 추가: prefix로 대응되는 (prev p → current c) 쌍 중 p가 초성-only인데 c가 초성-only가 아니면 재사용 불가(full scan). (isChosungOnlyToken은 현재 matchFields.ts 내부 함수 → internal/로 옮겨 공유하거나 export.)
- 보수안(간단):
hasChosungFalseField이고 이전 쿼리에 초성-only 토큰이 하나라도 있었으면 재사용 금지. 초성-only 토큰은 짧고 초기에만 나오므로 perf 손실 미미.
구현 위치: createSearcher의 멀티필드 빌더가 fields.some(f => f.chosung === false)를 계산해 makeRuntime에 전달(새 파라미터/재사용-predicate 훅), makeRuntime이 canReuse에 위 조건을 AND.
검증
- 위 재현 테스트가 통과해야 함.
test/에 회귀 테스트로 추가(순방향 타이핑 시퀀스 + fresh 비교).
- 기존 단조 케이스(모두 chosung:true, 단일 필드)의 세션 재사용은 그대로 유지되는지 확인.
npm test + npm run check:fix.
영향 범위
- 영향:
chosung:false 필드를 가진 멀티필드 createSearcher를 한 인스턴스로 순방향 타이핑에 쓰는 소비자(= command palette / 실시간 검색 전형).
- 비영향: 단일 필드, 모든 필드 chosung:true, fresh searcher/
matchFields 직접 호출.
참고 (문서)
CLAUDE.md는 "타이핑할수록 결과가 단조감소(monotonic narrowing)"를 핵심 속성으로 명시하지만 chosung:false 하드 필터가 이를 깬다. 세션을 고치거나, 최소한 이 상호작용을 문서화해야 한다.
소비자측 현황 (casely)
casely는 이 세션 버그를 피하려고 createSearcher 대신 matchFields를 아이템별로 직접 루프한다(세션 최적화 포기). fuzzly에서 세션이 고쳐지면 casely도 createSearcher로 되돌려 세션 이득을 취할 수 있다.
TL;DR
createSearcher멀티필드 모드에서chosung: false필드가 하나라도 있으면 세션 최적화(이전 매치만 재스캔)가 unsound하다. IME 순방향 타이핑처럼 한 searcher에 쿼리를 점점 늘려 호출하면, 제목/노트 등chosung:false필드로만 매치되는 항목이 실종된다. fresh searcher로는 찾아진다.createSearcher없이 재현되지 않으므로 세션 로직 버그다.재현 (test/에 drop-in, casely 무관)
관측:
근본 원인
두 기능의 상호작용:
세션 재사용 판정 —
src/createSearcher.ts,makeRuntime내부(대략 line 147-155):"atom-prefix 확장 = 매치 집합 단조 축소"를 가정하고
prevMatchedIndices만 재스캔한다.chosung 하드 필터 —
src/matchFields.ts:66:충돌: 초성-only 토큰
"ㅍ"(atoms=자음 하나)은"파"(atoms=자음+모음)의 atom-prefix다 →canReuse=true. 그러나"ㅍ"은 초성-only라title(chosung:false)에서 제외됐고,"파"는 초성-only가 아니라 제외되지 않는다. 따라서 title로만 매치되는 항목(id1)은"ㅍ"매치엔 없다가"파"매치엔 생긴다 → 매치 집합이 커진다(단조 축소 위반).prevMatchedIndices(id1 없음)만 재스캔하니 id1은 세션이 리셋될 때까지(쿼리가 짧아져 prefix 규칙이 깨질 때까지) 영구 실종.즉 "토큰의 초성-only 여부"는 atom-prefix에 대해 단조가 아니다 (모음 atom 하나가 붙으면 초성-only=true→false로 뒤집혀
chosung:false필드가 un-gate됨). 세션 판정은 이 전이를 무효화하지 않는다.chosung:false필드가 없으면(모두 chosung:true 또는 단일 필드) 단조성이 성립해 문제없다 — 그래서 이름 필드로 매치되는 항목은 멀쩡히 나온다.수정안
세션 재사용 가드를 chosung-aware로.
chosung:false필드가 있는 멀티필드 searcher에서만 적용.prevTokens와 함께 저장. 재사용 조건에 추가: prefix로 대응되는 (prev p → current c) 쌍 중 p가 초성-only인데 c가 초성-only가 아니면 재사용 불가(full scan). (isChosungOnlyToken은 현재matchFields.ts내부 함수 →internal/로 옮겨 공유하거나 export.)hasChosungFalseField이고 이전 쿼리에 초성-only 토큰이 하나라도 있었으면 재사용 금지. 초성-only 토큰은 짧고 초기에만 나오므로 perf 손실 미미.구현 위치:
createSearcher의 멀티필드 빌더가fields.some(f => f.chosung === false)를 계산해makeRuntime에 전달(새 파라미터/재사용-predicate 훅),makeRuntime이canReuse에 위 조건을 AND.검증
test/에 회귀 테스트로 추가(순방향 타이핑 시퀀스 + fresh 비교).npm test+npm run check:fix.영향 범위
chosung:false필드를 가진 멀티필드createSearcher를 한 인스턴스로 순방향 타이핑에 쓰는 소비자(= command palette / 실시간 검색 전형).matchFields직접 호출.참고 (문서)
CLAUDE.md는 "타이핑할수록 결과가 단조감소(monotonic narrowing)"를 핵심 속성으로 명시하지만chosung:false하드 필터가 이를 깬다. 세션을 고치거나, 최소한 이 상호작용을 문서화해야 한다.소비자측 현황 (casely)
casely는 이 세션 버그를 피하려고
createSearcher대신matchFields를 아이템별로 직접 루프한다(세션 최적화 포기). fuzzly에서 세션이 고쳐지면 casely도createSearcher로 되돌려 세션 이득을 취할 수 있다.