fix(tokenizer): exclude unparsed-run tokens from annotations and N+1 (#153)

This commit is contained in:
2026-07-09 00:28:38 -07:00
committed by GitHub
parent ae40934d3a
commit 6c251502b3
13 changed files with 138 additions and 36 deletions
+4 -2
View File
@@ -1,4 +1,6 @@
type: fixed
type: changed
area: overlay
- Fixed words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" matching a known 床/ゆか "floor" card). The known-word cache now stores each card's word together with its reading and only matches when the token's reading agrees; cards without a reading field keep matching in any reading as before.
- New reading-aware subtitle parsing and known-word matching: the known-word cache now stores each Anki card's word together with its reading (cache format v3), and a token only gets the known-word highlight when its parsed reading agrees with the card. The cache and the stats server upgrade automatically.
- Fixes words being highlighted green as known when a same-spelled Anki card taught a different reading (e.g. とこ parsed as 床 "bed" no longer matches a known 床/ゆか "floor" card). Cards without a reading field keep matching in any reading as before.
- Single-kana grammar tokens (よ in 全然いいよ, standalone え) no longer borrow the reading of an unrelated card (such as 夜 or 絵) and get painted as known: reading-only matching requires at least two kana, while single-kana cards still match by their word field.
+1 -2
View File
@@ -1,5 +1,4 @@
type: fixed
type: changed
area: overlay
- Fixed single-kana grammar tokens counting as known words by borrowing the reading of an unrelated Anki note (よ in 全然いいよ matched a card read よ such as 夜, standalone え matched 絵), which painted them with the known-word highlight. Reading-only known-word matching now requires at least two kana; single-kana cards still match by their word field, and tokens genuinely present in the known-words cache (e.g. です) keep their highlight.
- Standalone suffix tokens (MeCab pos2 接尾, e.g. さん, れる) are now excluded from JLPT/frequency/N+1 annotations by default, matching how particles and interjections are treated. Cache-backed known-word highlighting still applies; override via the pos2 exclusion config if you want them annotated.
-4
View File
@@ -1,4 +0,0 @@
type: fixed
area: stats
- Session known-word counts no longer show 0 everywhere. The stats server's known-word cache parser only understood the v1/v2 cache formats, so after the reading-aware v3 cache upgrade it silently treated the cache as missing; it now flattens v3 note entries into the headword set.
+1 -1
View File
@@ -1,4 +1,4 @@
type: fixed
area: overlay
- Fixed subtitle text that Yomitan's parser cannot match (e.g. the truncated volitional in とこ戻ろ…) being rendered as plain, non-interactive text: it was invisible to hover/lookup and excluded from the n+1 word count, which could wrongly mark a sentence as n+1. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation; bracketed SFX/speaker captions and punctuation-only runs are still skipped.
- Fixed subtitle text that Yomitan's parser cannot match (truncated inflections like the volitional in とこ戻ろ…, elongation runs like ぅ~/ぉ〜) being rendered as plain, non-interactive text with no hover lookup. Unparsed runs are now kept as hoverable tokens matching Yomitan's own segmentation, while being fully ignored by frequency/JLPT highlighting, the N+1 candidate math, and vocabulary stats. Bracketed SFX/speaker captions and punctuation-only runs are still skipped.
+34
View File
@@ -28,6 +28,7 @@ interface YomitanTokenInput {
frequencyRank?: number;
isNameMatch?: boolean;
wordClasses?: string[];
isUnparsedRun?: boolean;
}
function makeDepsFromYomitanTokens(
@@ -60,6 +61,7 @@ function makeDepsFromYomitanTokens(
isNameMatch: token.isNameMatch ?? false,
frequencyRank: token.frequencyRank,
wordClasses: token.wordClasses,
isUnparsedRun: token.isUnparsedRun,
};
});
},
@@ -4223,6 +4225,38 @@ test('tokenizeSubtitle clears all annotations for explanatory pondering endings'
);
});
test('tokenizeSubtitle ignores unparsed-run tokens for annotations and N+1', async () => {
// もう いるぅ~!: the ぅ~ elongation has no Yomitan dictionary entry; it must
// not become the sole N+1 candidate or receive frequency/JLPT annotations.
const result = await tokenizeSubtitle(
'もう いるぅ~!',
makeDepsFromYomitanTokens(
[
{ surface: 'もう', reading: 'もう', headword: 'もう' },
{ surface: 'いる', reading: 'いる', headword: 'いる' },
{ surface: 'ぅ~', reading: '', headword: 'ぅ~', isUnparsedRun: true, frequencyRank: 999 },
],
{
getFrequencyDictionaryEnabled: () => true,
getJlptLevel: (text) => (text === 'ぅ~' ? 'N5' : null),
isKnownWord: (text) => text === 'もう' || text === 'いる',
getMinSentenceWordsForNPlusOne: () => 2,
tokenizeWithMecab: async () => null,
},
),
);
const filler = result.tokens?.find((token) => token.surface === 'ぅ~');
assert.ok(filler);
assert.equal(filler?.isNPlusOneTarget, false);
assert.equal(filler?.frequencyRank, undefined);
assert.equal(filler?.jlptLevel, undefined);
assert.equal(
result.tokens?.some((token) => token.isNPlusOneTarget),
false,
);
});
test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => {
const result = await tokenizeSubtitle(
'張り切ってんじゃ',
+1
View File
@@ -734,6 +734,7 @@ async function parseWithYomitanInternalParser(
isNPlusOneTarget: false,
isNameMatch: token.isNameMatch ?? false,
frequencyRank: token.frequencyRank,
isUnparsedRun: token.isUnparsedRun === true ? true : undefined,
};
}),
);
@@ -208,6 +208,48 @@ test('annotateTokens hides known-word marks while still using known words for N+
assert.equal(result[2]?.isNPlusOneTarget, true);
});
test('shouldExcludeTokenFromSubtitleAnnotations excludes unparsed-run tokens', () => {
// 戻 from 「…とこ戻ろ…」: Yomitan had no dictionary entry, headword falls back
// to the surface. Without the flag the token passes every other filter.
const unflagged = makeToken({ surface: '戻', headword: '戻', reading: '' });
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(unflagged), false);
const flagged = makeToken({ surface: '戻', headword: '戻', reading: '', isUnparsedRun: true });
assert.equal(shouldExcludeTokenFromSubtitleAnnotations(flagged), true);
assert.equal(shouldExcludeTokenFromVocabularyPersistence(flagged), true);
});
test('annotateTokens ignores unparsed-run tokens for annotations and N+1', () => {
// もう いるぅ~!-style line: the elongation run is the only unknown token and
// used to become the sole N+1 candidate despite having no dictionary entry.
const tokens = [
makeToken({ surface: 'みんな', headword: '皆', reading: 'みんな', startPos: 0, endPos: 3 }),
makeToken({ surface: 'とこ', headword: '所', reading: 'とこ', startPos: 3, endPos: 5 }),
makeToken({
surface: '戻',
headword: '戻',
reading: '',
startPos: 5,
endPos: 6,
isUnparsedRun: true,
frequencyRank: 12,
}),
];
const result = annotateTokens(
tokens,
makeDeps({
isKnownWord: (text) => text === '皆' || text === '所',
getJlptLevel: (text) => (text === '戻' ? 'N5' : null),
}),
{ minSentenceWordsForNPlusOne: 2 },
);
assert.equal(result[2]?.isNPlusOneTarget, false);
assert.equal(result[2]?.jlptLevel, undefined);
assert.equal(result[2]?.frequencyRank, undefined);
});
test('annotateTokens falls back to reading for known-word matches when headword lookup misses', () => {
const tokens = [
makeToken({
@@ -146,13 +146,17 @@ test('emits unparsed non-caption text as a token with surface headword', () => {
const tokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
assert.deepEqual(
tokens?.map((token) => ({ surface: token.surface, headword: token.headword })),
tokens?.map((token) => ({
surface: token.surface,
headword: token.headword,
isUnparsedRun: token.isUnparsedRun ?? false,
})),
[
{ surface: 'みんな', headword: '皆' },
{ surface: 'の', headword: 'の' },
{ surface: 'とこ', headword: '所' },
{ surface: '戻', headword: '戻' },
{ surface: 'ろ', headword: '櫓' },
{ surface: 'みんな', headword: '皆', isUnparsedRun: false },
{ surface: 'の', headword: 'の', isUnparsedRun: false },
{ surface: 'とこ', headword: '所', isUnparsedRun: false },
{ surface: '戻', headword: '戻', isUnparsedRun: true },
{ surface: 'ろ', headword: '櫓', isUnparsedRun: false },
],
);
});
@@ -239,6 +239,7 @@ export function mapYomitanParseResultItemToMergedTokens(
headword: string,
start: number,
end: number,
isUnparsedRun = false,
): void => {
tokens.push({
surface,
@@ -254,6 +255,7 @@ export function mapYomitanParseResultItemToMergedTokens(
const matchText = resolveKnownWordText(surface, headword, knownWordMatchMode);
return matchText ? isKnownWord(matchText) : false;
})(),
...(isUnparsedRun ? { isUnparsedRun: true } : {}),
});
};
@@ -275,10 +277,11 @@ export function mapYomitanParseResultItemToMergedTokens(
previousToken.reading += combinedReading;
previousToken.endPos = end;
} else if (shouldEmitUnparsedRunAsToken(combinedSurface)) {
// Yomitan couldn't parse this run (e.g. 戻ろ… truncated volitional). Keep it
// as a token with its surface as headword so it stays hoverable and counts in
// the n+1 math — matching what the embedded Yomitan actually returns.
pushToken(combinedSurface, combinedReading, combinedSurface, combinedStart, end);
// Yomitan couldn't parse this run (e.g. 戻ろ… truncated volitional, ぅ~
// elongations). Keep it as a token with its surface as headword so it stays
// hoverable, but flag it so annotation/N+1/vocab logic ignores it — there is
// no dictionary entry behind it.
pushToken(combinedSurface, combinedReading, combinedSurface, combinedStart, end, true);
}
} else {
hasDictionaryMatch = true;
@@ -474,6 +474,13 @@ export function shouldExcludeTokenFromSubtitleAnnotations(
token: MergedToken,
options: SubtitleAnnotationFilterOptions = {},
): boolean {
// No Yomitan dictionary entry backs this token (ぅ~ elongations, truncated
// inflections) — it exists only to stay hoverable and must never receive
// annotations or count in the N+1 math.
if (token.isUnparsedRun === true) {
return true;
}
const pos1Exclusions = resolvePos1Exclusions(options);
const pos2Exclusions = resolvePos2Exclusions(options);
const normalizedPos1 = normalizePosTag(token.pos1);
@@ -934,6 +934,7 @@ test('requestYomitanScanTokens keeps scanner metadata for matching spans when pa
headword: 'っ ',
startPos: 2,
endPos: 4,
isUnparsedRun: true,
},
{
surface: 'ミナト',
@@ -57,6 +57,7 @@ export interface YomitanScanToken {
isNameMatch?: boolean;
frequencyRank?: number;
wordClasses?: string[];
isUnparsedRun?: boolean;
}
interface YomitanProfileMetadata {
@@ -110,6 +111,27 @@ function scanTokenSpanKey(token: YomitanScanToken): string {
return `${token.startPos}:${token.endPos}:${token.surface}`;
}
// Maps a parse-selected token to the scanner-token shape carried out of the
// parser runtime. Shared by both selectYomitanParseTokens fallback paths so the
// projected fields stay in sync as the shape changes.
function toYomitanScanToken(token: {
surface: string;
reading: string;
headword: string;
startPos: number;
endPos: number;
isUnparsedRun?: boolean;
}): YomitanScanToken {
return {
surface: token.surface,
reading: token.reading,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
...(token.isUnparsedRun === true ? { isUnparsedRun: true } : {}),
};
}
// parseText segmentation is authoritative (it emits filler chunks for text the
// termsFind scanner skips), but only the termsFind scanner carries annotation
// metadata (isNameMatch, frequencyRank, headwordReading, wordClasses). Graft
@@ -1611,14 +1633,7 @@ export async function requestYomitanScanTokens(
const parseResults = await requestYomitanParseResults(text, deps, logger);
const selectedParseTokens = selectYomitanParseTokens(parseResults, () => false, 'headword');
const parseScanTokens =
selectedParseTokens?.map((token) => ({
surface: token.surface,
reading: token.reading,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
})) ?? null;
const parseScanTokens = selectedParseTokens?.map(toYomitanScanToken) ?? null;
const metadata = await requestYomitanProfileMetadata(parserWindow, logger);
const profileIndex = metadata?.profileIndex ?? 0;
@@ -1656,15 +1671,7 @@ export async function requestYomitanScanTokens(
}
if (Array.isArray(rawResult)) {
const selectedTokens = selectYomitanParseTokens(rawResult, () => false, 'headword');
return (
selectedTokens?.map((token) => ({
surface: token.surface,
reading: token.reading,
headword: token.headword,
startPos: token.startPos,
endPos: token.endPos,
})) ?? null
);
return selectedTokens?.map(toYomitanScanToken) ?? null;
}
if (parseScanTokens && parseScanTokens.length > 0) {
return parseScanTokens;
+6
View File
@@ -40,6 +40,12 @@ export interface MergedToken {
isMerged: boolean;
isKnown: boolean;
isNPlusOneTarget: boolean;
/**
* Text Yomitan had no dictionary entry for (e.g. ぅ~ elongation runs,
* truncated inflections). Kept as a token so it stays hoverable, but
* ignored by annotation, N+1, and vocabulary-stats logic.
*/
isUnparsedRun?: boolean;
isNameMatch?: boolean;
characterImage?: CharacterNameImage;
jlptLevel?: JlptLevel;