Files
PubScript/packages/core/src/fuzzy.ts
T
Schwitzz 4efa71515e feat(entity): Chunk #4 data layer + entity UI (characters & world-building)
Data layer:
- Fuzzy scorer (packages/core/src/fuzzy.ts) with index recovery + unit tests
- Backlink computation (packages/core/src/backlinks.ts) incl. entity-level backlinks
- Consistency checker (packages/core/src/consistency.ts) incl. reciprocity/scope + unit tests
- StorageAdapter contract extended with listEntities/searchEntities/saveEntity/deleteEntity;
  implemented in Local (Dexie) and Server adapters; contract tests cover entity CRUD + ?q= search
- WorldItem.fields now supports list values (keyPeople, rivals); expanded RelationshipKind
- @mention autocomplete extension + picker in the editor package

Entity UI (apps/web/src/features/entities/):
- Generic field renderer (EntityFields) driven by ENTITY_FIELDS/FieldDef
- Tabbed directory with fuzzy search + entity cards (EntitiesView)
- Create/edit/delete drawer (EntityEditor) reusing shared fields
- Relationship editor + force-directed SVG relationship map
- Entity engine stylesheet; wired into nav

Store: project-store entity methods (load/create/update/delete) over the StorageAdapter contract.

CI-green: typecheck, lint, 65 tests, and web build all pass.
2026-10-25 22:42:55 -05:00

112 lines
3.7 KiB
TypeScript

/**
* Dependency-light fuzzy scorer used for entity search and mention
* autocomplete.
*
* This is a subsequence scorer in the spirit of "sublime" / Fuse.js: a query is
* a sequence of characters that must appear (in order) within a target string.
* It scores matches so that:
*
* - consecutive matches score higher than scattered ones,
* - matches at the start of a word (after a separator) score higher,
* - earlier matches in the string score higher.
*
* The result is a number in `[0, 1]` where `1` is a perfect (whole-string,
* case-insensitive, consecutive) match. It is intentionally allocation-light
* and fast enough to run on every keystroke against a few hundred entities
* well under the 50 ms budget the plan calls for.
*/
const WORD_SEP = /[^a-zA-Z0-9]+/;
export interface FuzzyResult {
/** Match score in [0, 1]; 0 means no match. */
score: number;
/** Indices in `target` that were matched by the query, in order. */
indices: number[];
}
/** Score a single matched cell from its predecessor score. */
function scoreCell(prev: number, consecutive: number, wordStart: number): number {
let s = prev + 1; // base reward for a match
if (consecutive) s *= 1.4; // multiplicative streak bonus
if (wordStart) s += 0.8; // word-start bonus
return s;
}
/** Normalize a raw score into [0, 1]. */
function normalize(raw: number): number {
if (!isFinite(raw)) return 0;
return Math.max(0, Math.min(1, raw / 3));
}
/**
* Score `query` against `target`. Returns `{ score: 0 }` when the query is not a
* subsequence of the target (i.e. no match).
*/
export function fuzzyScore(query: string, target: string): FuzzyResult {
const q = query;
const t = target;
if (q.length === 0) return { score: 0, indices: [] };
if (q.length > t.length) return { score: 0, indices: [] };
const ql = q.length;
const tl = t.length;
const tq = q.toLowerCase();
const tt = t.toLowerCase();
// best[i] = best score having matched the first i query chars.
// where[i] = target index at which the i-th match landed (for recovery).
const best = new Float64Array(ql + 1).fill(-Infinity);
const where = new Int32Array(ql + 1);
const consec = new Uint8Array(ql + 1);
best[0] = 0;
for (let ti = 0; ti < tl; ti++) {
const tc = tt[ti]!;
// Walk from high to low so each cell reads the previous row only.
for (let i = ql; i >= 1; i--) {
const qc = tq[i - 1]!;
if (tc !== qc) continue;
const fromPrev = best[i - 1]!;
if (fromPrev <= -Infinity) continue;
const wordStart = ti === 0 || WORD_SEP.test(t[ti - 1]!) ? 1 : 0;
const c = consec[i - 1] ? 1 : 0;
const s = scoreCell(fromPrev, c, wordStart);
if (s > best[i]!) {
best[i] = s;
where[i] = ti;
consec[i] = c;
}
}
}
if (best[ql]! <= -Infinity) return { score: 0, indices: [] };
// Recover matched indices by walking `where` backwards.
const indices: number[] = [];
for (let i = ql; i >= 1; i--) {
indices.push(where[i]!);
}
indices.reverse();
return { score: normalize(best[ql]!), indices };
}
/** Convenience: does `query` match `target` as a subsequence? */
export function fuzzyMatch(query: string, target: string): boolean {
return fuzzyScore(query, target).score > 0;
}
/**
* Sort `items` by descending fuzzy score against `query`, dropping non-matches.
* Stable: equal scores keep input order.
*/
export function fuzzySort<T>(items: T[], query: string, pick: (item: T) => string): T[] {
if (!query) return items.slice();
const scored = items
.map((item) => ({ item, res: fuzzyScore(query, pick(item)) }))
.filter((r) => r.res.score > 0);
scored.sort((a, b) => b.res.score - a.res.score);
return scored.map((r) => r.item);
}