4efa71515e
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.
112 lines
3.7 KiB
TypeScript
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);
|
|
}
|