fix(tokenizer): block kanji reading collisions and name/generic length t

- Add allowReadingOnlyMatch:false to kanji token known-word lookups so 渓谷/けいこく no longer matches a mined 警告/けいこく card via reading-only index
- Greedy name pre-pass yields when a strictly longer generic word starts at the same position (空 no longer splits 空気; ties still go to the name)
This commit is contained in:
2026-07-09 01:03:58 -07:00
parent 6c251502b3
commit db4139ba0b
12 changed files with 237 additions and 15 deletions
+6 -2
View File
@@ -703,8 +703,12 @@ export class AnkiIntegration {
});
}
isKnownWord(text: string, reading?: string): boolean {
return this.knownWordCache.isKnownWord(text, reading);
isKnownWord(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): boolean {
return this.knownWordCache.isKnownWord(text, reading, options);
}
getKnownWordMatchMode(): NPlusOneMatchMode {
@@ -734,6 +734,42 @@ test('KnownWordCacheManager disambiguates known words by note reading', async ()
}
});
test('KnownWordCacheManager suppresses reading-only matches when disallowed', async () => {
const config: AnkiConnectConfig = {
fields: {
word: 'Word',
},
knownWords: {
highlightEnabled: true,
},
};
const { manager, clientState, cleanup } = createKnownWordCacheHarness(config);
try {
clientState.findNotesResult = [1];
clientState.notesInfoResult = [
{
noteId: 1,
fields: {
Word: { value: '警告' },
'Word Reading': { value: 'けいこく' },
},
},
];
await manager.refresh(true);
// Reading-only match stays available for kana subtitle text…
assert.equal(manager.isKnownWord('けいこく'), true);
// …but a kanji token's reading (渓谷/けいこく) must not borrow 警告's.
assert.equal(manager.isKnownWord('けいこく', undefined, { allowReadingOnlyMatch: false }), false);
// Mined word texts still match regardless of the flag.
assert.equal(manager.isKnownWord('警告', undefined, { allowReadingOnlyMatch: false }), true);
} finally {
cleanup();
}
});
test('KnownWordCacheManager does not match single-kana text by reading alone', async () => {
const config: AnkiConnectConfig = {
fields: {
+13 -1
View File
@@ -142,7 +142,11 @@ export class KnownWordCacheManager {
);
}
isKnownWord(text: string, reading?: string): boolean {
isKnownWord(
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
): boolean {
if (!this.isKnownWordCacheEnabled()) {
return false;
}
@@ -163,6 +167,14 @@ export class KnownWordCacheManager {
);
}
// Callers that look up a kanji token's reading (not subtitle text) must
// opt out of the reading-only fallback: readingCounts holds readings of
// every note including kanji words, so 渓谷's けいこく would match a
// mined 警告/けいこく.
if (options?.allowReadingOnlyMatch === false) {
return false;
}
// Reading-only fallback, except for single-kana text: particles and
// interjections (よ, ね, え…) would otherwise borrow the reading of an
// unrelated note (夜「よ」, 絵「え」) and count as known.
+12 -3
View File
@@ -33,6 +33,15 @@ type MecabTokenEnrichmentFn = (
mecabTokens: MergedToken[] | null,
) => Promise<MergedToken[]>;
// allowReadingOnlyMatch: false suppresses the cache's reading-only index for
// lookups that pass a kanji token's reading as the text (see
// computeTokenKnownStatus in annotation-stage).
export type KnownWordLookupFn = (
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
) => boolean;
export interface TokenizerServiceDeps {
getYomitanExt: () => Extension | null;
getYomitanSession?: () => Session | null;
@@ -42,7 +51,7 @@ export interface TokenizerServiceDeps {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string, reading?: string) => boolean;
isKnownWord: KnownWordLookupFn;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -77,7 +86,7 @@ export interface TokenizerDepsRuntimeOptions {
setYomitanParserReadyPromise: (promise: Promise<void> | null) => void;
getYomitanParserInitPromise: () => Promise<boolean> | null;
setYomitanParserInitPromise: (promise: Promise<boolean> | null) => void;
isKnownWord: (text: string, reading?: string) => boolean;
isKnownWord: KnownWordLookupFn;
getKnownWordMatchMode: () => NPlusOneMatchMode;
getKnownWordsEnabled?: () => boolean;
getJlptLevel: (text: string) => JlptLevel | null;
@@ -129,7 +138,7 @@ const INVISIBLE_SEPARATOR_PATTERN = /[\u200b\u2060\ufeff]/g;
function getKnownWordLookup(
deps: TokenizerServiceDeps,
options: TokenizerAnnotationOptions,
): (text: string, reading?: string) => boolean {
): KnownWordLookupFn {
if (!options.knownWordsEnabled && !options.nPlusOneEnabled) {
return () => false;
}
@@ -273,6 +273,29 @@ test('annotateTokens falls back to reading for known-word matches when headword
assert.equal(result[0]?.frequencyRank, 1895);
});
test('annotateTokens reading fallback does not match kanji tokens sharing a mined reading', () => {
const tokens = [
makeToken({
surface: '渓谷',
headword: '渓谷',
reading: 'けいこく',
endPos: 2,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
// Mimics the cache with a mined 警告/けいこく: けいこく matches through
// the reading-only index unless the lookup opts out of it.
isKnownWord: (text, _reading, options) =>
text === '警告' || (options?.allowReadingOnlyMatch !== false && text === 'けいこく'),
}),
);
assert.equal(result[0]?.isKnown, false);
});
test('annotateTokens ignores partial furigana readings for known-word fallback', () => {
const tokens = [
makeToken({
@@ -26,7 +26,11 @@ const jlptLevelLookupCaches = new WeakMap<
>();
export interface AnnotationStageDeps {
isKnownWord: (text: string, reading?: string) => boolean;
isKnownWord: (
text: string,
reading?: string,
options?: { allowReadingOnlyMatch?: boolean },
) => boolean;
knownWordMatchMode: NPlusOneMatchMode;
getJlptLevel: (text: string) => JlptLevel | null;
}
@@ -654,7 +658,7 @@ function resolveKnownWordReadingForMatch(
function computeTokenKnownStatus(
token: MergedToken,
isKnownWord: (text: string, reading?: string) => boolean,
isKnownWord: AnnotationStageDeps['isKnownWord'],
knownWordMatchMode: NPlusOneMatchMode,
): boolean {
const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode);
@@ -668,7 +672,14 @@ function computeTokenKnownStatus(
return false;
}
return fallbackReading !== matchText.trim() && isKnownWord(fallbackReading);
// This fallback covers words mined in kana (token 大体, mined word だいたい),
// so the reading must only match mined word texts — the cache's reading-only
// index would let any kanji token match an unrelated note that shares its
// reading (渓谷/けいこく vs a mined 警告/けいこく).
return (
fallbackReading !== matchText.trim() &&
isKnownWord(fallbackReading, undefined, { allowReadingOnlyMatch: false })
);
}
function filterTokenFrequencyRank(
@@ -1995,6 +1995,107 @@ test('requestYomitanScanTokens greedily tokenizes character names before longer
);
});
test('requestYomitanScanTokens lets a longer generic word beat a shorter name at the same position', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
if (script.includes('termsFind')) {
scannerScript = script;
return [];
}
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [
{ name: 'JMdict', enabled: true },
{ name: 'SubMiner Character Dictionary (AniList 130298)', enabled: true },
],
},
},
],
};
}
return null;
});
await requestYomitanScanTokens(
'空気変わって',
deps,
{ error: () => undefined },
{ includeNameMatchMetadata: true },
);
assert.match(scannerScript, /const greedyNameScanEnabled = true;/);
const nameEntry = (term: string, reading: string) => ({
headwords: [
{
term,
reading,
sources: [{ originalText: term, isPrimary: true, matchType: 'exact' }],
},
],
definitions: [
{
dictionary: 'SubMiner Character Dictionary (AniList 130298)',
dictionaryAlias: 'SubMiner Character Dictionary (AniList 130298)',
},
],
});
const jmdictEntry = (term: string, reading: string, originalText: string) => ({
headwords: [
{
term,
reading,
sources: [{ originalText, isPrimary: true, matchType: 'exact' }],
},
],
definitions: [{ dictionary: 'JMdict', dictionaryAlias: 'JMdict' }],
});
const result = await runInjectedYomitanScript(scannerScript, (action, params) => {
if (action !== 'termsFind') {
throw new Error(`unexpected action: ${action}`);
}
const text = (params as { text?: string } | undefined)?.text ?? '';
if (text.startsWith('空気')) {
// A character named 空 matches here, but the generic 空気 is longer and
// must win the position.
return {
originalTextLength: 2,
dictionaryEntries: [nameEntry('空', 'くう'), jmdictEntry('空気', 'くうき', '空気')],
};
}
if (text.startsWith('変わって')) {
return {
originalTextLength: 4,
dictionaryEntries: [jmdictEntry('変わる', 'かわる', '変わって')],
};
}
return { originalTextLength: 0, dictionaryEntries: [] };
});
assert.equal(Array.isArray(result), true);
assert.deepEqual(
(result as Array<Record<string, unknown>>).map(
({ surface, headword, startPos, endPos, isNameMatch }) => ({
surface,
headword,
startPos,
endPos,
isNameMatch,
}),
),
[
{ surface: '空気', headword: '空気', startPos: 0, endPos: 2, isNameMatch: false },
{ surface: '変わって', headword: '変わる', startPos: 2, endPos: 6, isNameMatch: false },
],
);
});
test('requestYomitanScanTokens skips greedy name scan without an enabled character dictionary', async () => {
let scannerScript = '';
const deps = createDeps(async (script) => {
@@ -1304,6 +1304,22 @@ const YOMITAN_SCANNING_HELPERS = String.raw`
}
return best;
}
function findLongestGenericMatchLength(dictionaryEntries, textWindow) {
let best = 0;
for (const dictionaryEntry of dictionaryEntries || []) {
if (isNameDictionaryEntry(dictionaryEntry)) { continue; }
const headwords = Array.isArray(dictionaryEntry?.headwords) ? dictionaryEntry.headwords : [];
for (const headword of headwords) {
for (const src of headword?.sources || []) {
if (src.matchType !== 'exact' || src.isPrimary !== true) { continue; }
const originalText = typeof src.originalText === 'string' ? src.originalText : '';
if (!originalText || !textWindow.startsWith(originalText)) { continue; }
if (originalText.length > best) { best = originalText.length; }
}
}
}
return best;
}
function getPreferredHeadword(dictionaryEntries, token, dictionaryPriorityByName, dictionaryFrequencyModeByName) {
const currentMediaDictionaryEntries =
currentCharacterDictionaryMediaId === null
@@ -1455,8 +1471,16 @@ ${YOMITAN_SCANNING_HELPERS}
}
const result = await termsFindAt(namePos, ${scanLength});
const dictionaryEntries = Array.isArray(result?.dictionaryEntries) ? result.dictionaryEntries : [];
const nameMatch = findLongestNameMatch(dictionaryEntries, text.substring(namePos, namePos + ${scanLength}));
if (!nameMatch) {
const textWindow = text.substring(namePos, namePos + ${scanLength});
const nameMatch = findLongestNameMatch(dictionaryEntries, textWindow);
// A name only claims its span when no strictly longer generic word
// starts at the same position (a character named 空 must not split
// 空気). Ties go to the name. Generic matches that start earlier and
// overlap the name are still blocked by the reservation.
if (
!nameMatch ||
findLongestGenericMatchLength(dictionaryEntries, textWindow) > nameMatch.sourceLength
) {
namePos += String.fromCodePoint(codePoint).length;
continue;
}
+2 -1
View File
@@ -4652,7 +4652,8 @@ const {
setYomitanParserInitPromise: (promise) => {
appState.yomitanParserInitPromise = promise;
},
isKnownWord: (text, reading) => Boolean(appState.ankiIntegration?.isKnownWord(text, reading)),
isKnownWord: (text, reading, options) =>
Boolean(appState.ankiIntegration?.isKnownWord(text, reading, options)),
recordLookup: (hit) => {
ensureImmersionTrackerStarted();
appState.immersionTracker?.recordLookup(hit);
@@ -37,8 +37,8 @@ export function createBuildTokenizerDepsMainHandler(deps: TokenizerMainDeps) {
getYomitanParserInitPromise: () => deps.getYomitanParserInitPromise(),
setYomitanParserInitPromise: (promise: Promise<boolean> | null) =>
deps.setYomitanParserInitPromise(promise),
isKnownWord: (text: string, reading?: string) => {
const hit = deps.isKnownWord(text, reading);
isKnownWord: (text, reading, options) => {
const hit = deps.isKnownWord(text, reading, options);
deps.recordLookup(hit);
return hit;
},