fix(facts): quarantine ambiguous entity matches (#2723) (#2737)

Bare names resolve only when prefix expansion finds exactly one canonical candidate; ambiguous collisions and low-specificity multi-token fuzzy matches fall through to the guarded holding path instead of confident wrong attribution. Fixes #2723.
This commit is contained in:
Time Attakc
2026-07-13 00:09:04 -07:00
committed by GitHub
parent 42ab0956a4
commit 68ed7bafa4
3 changed files with 57 additions and 26 deletions
+34 -19
View File
@@ -10,15 +10,15 @@
* Lives under `src/core/entities/` so signal-detector can reuse it for the
* Sonnet pass too without circular import through facts/.
*
* Prefix-expansion step lives between fuzzy match and slugify fallback.
* Bare-name prefix expansion lives before fuzzy match and slugify fallback.
* Bare first names like "Alice" score too low on pg_trgm (short strings
* have terrible trigram overlap), so without this step they fall through
* to slugify("Alice") → "alice", which spawns a phantom `people/alice.md`
* stub at brain root instead of resolving to the existing
* `people/alice-example` page. The fix queries `slug LIKE 'people/X-%'`
* (then `companies/X-%`) when fuzzy fails on a single-word bare name, and
* uses connection count (links + chunks) as the tiebreaker when multiple
* candidates match.
* resolves only a single candidate; collisions fall through to the guarded
* unprefixed holding path rather than guessing from connection count.
*/
import type { BrainEngine } from '../engine.ts';
@@ -29,9 +29,10 @@ import type { BrainEngine } from '../engine.ts';
* Resolution order:
* 1. If `raw` is already a page slug shape (contains a "/" or matches an
* exact pages.slug row in this source), return it untouched.
* 2. Try fuzzy match against pages.slug + pages.title within the source
* (case-insensitive). Pick the highest-trgm-score match if any.
* 3. Fall back to a deterministic slugify: lowercase-no-spaces with
* 2. Resolve a bare name only when prefix expansion finds one candidate.
* 3. For multi-token input, require a high-specificity fuzzy match against
* pages.slug + pages.title within the source (case-insensitive).
* 4. Fall back to a deterministic slugify: lowercase-no-spaces with
* hyphen-collapse. NOT prefixed with a directory — caller decides
* whether to prefix `people/`, `companies/`, etc.
*
@@ -54,21 +55,22 @@ export async function resolveEntitySlug(
if (exact) return exact;
}
// 2. Fuzzy match against existing pages within the source. Match either
// on slug fragment or on title.
const fuzzy = await tryFuzzyMatch(engine, source_id, trimmed);
if (fuzzy) return fuzzy;
// 3. Prefix-expansion match: when the input looks like a bare first name
// 2. Prefix-expansion match: when the input looks like a bare first name
// (no slash, no prefix, slugifies to a single short token), try
// `people/<token>-%` then `companies/<token>-%`. Short bare names
// score terribly on pg_trgm — similarity('alice', 'alice-example')
// is below the 0.4 threshold — so this is the layer that catches
// is below the fuzzy threshold — so this is the layer that catches
// `"Alice"` → `people/alice-example` before we phantom-stub a bare
// `people/alice.md`.
if (isBareName(trimmed)) {
const expanded = await tryPrefixExpansion(engine, source_id, slugify(trimmed));
const expanded = await tryUnambiguousPrefixExpansion(engine, source_id, slugify(trimmed));
if (expanded) return expanded;
} else {
// 3. Fuzzy match against existing pages within the source. Bare names
// deliberately skip this arm: a shared first name is not specific
// enough to choose one person by trigram score or popularity.
const fuzzy = await tryFuzzyMatch(engine, source_id, trimmed);
if (fuzzy) return fuzzy;
}
// 4. Fallback: deterministic slugify.
@@ -131,12 +133,12 @@ export async function resolveEntitySlugWithSource(
if (exact) return { slug: exact, source: 'exact_page' };
}
const fuzzy = await tryFuzzyMatch(engine, source_id, trimmed);
if (fuzzy) return { slug: fuzzy, source: 'fuzzy_match' };
if (isBareName(trimmed)) {
const expanded = await tryPrefixExpansion(engine, source_id, slugify(trimmed));
const expanded = await tryUnambiguousPrefixExpansion(engine, source_id, slugify(trimmed));
if (expanded) return { slug: expanded, source: 'fuzzy_match' };
} else {
const fuzzy = await tryFuzzyMatch(engine, source_id, trimmed);
if (fuzzy) return { slug: fuzzy, source: 'fuzzy_match' };
}
return { slug: slugify(trimmed), source: 'fallback_slugify' };
@@ -240,6 +242,15 @@ export async function findPrefixCandidates(
}
}
async function tryUnambiguousPrefixExpansion(
engine: BrainEngine,
source_id: string,
token: string,
): Promise<string | null> {
const candidates = await findPrefixCandidates(engine, source_id, token);
return candidates.length === 1 ? candidates[0].slug : null;
}
/**
* Look up pages whose slug starts with `<dir>/<token>-` for each known
* entity directory. When multiple candidates match within a directory,
@@ -359,7 +370,11 @@ async function tryFuzzyMatch(
LIMIT 3`,
[source_id, lc, fragment],
);
if (rows.length > 0 && rows[0].score >= 0.4) return rows[0].slug;
// 0.4 confidently misattributes names that share only a generic company
// token (for example "Beacon Capital" → "Benton Capital"). Keep fuzzy
// typo tolerance, but require high-specificity overlap before writing a
// fact to an existing entity.
if (rows.length > 0 && rows[0].score >= 0.7) return rows[0].slug;
} catch {
// pg_trgm functions might not be available on every engine config;
// fall through to slugify.