fix(anki): exclude learning cards from interval maturity tiers

The interval tier queries matched any card with prop:ivl>=1, but a lapsed
card keeps an interval of at least the lapse minimum, so relearning cards
were caught by the young query first and the learning tier was unreachable
in practice. Add -is:learn to the mature and young queries so the buckets
stay disjoint and match Anki's own card counts; a note with a mature card
alongside a relearning card still resolves to mature.

Bump KNOWN_WORD_MATURITY_RULES_VERSION and fold it into the cache scope key
so caches built under the old rules refetch instead of serving stale tiers.
This commit is contained in:
2026-07-26 01:32:50 -07:00
parent 77badd7a54
commit 38060e0ead
6 changed files with 76 additions and 35 deletions
@@ -2,3 +2,4 @@ type: added
area: overlay area: overlay
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on. - Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on.
- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature.
+7 -5
View File
@@ -49,23 +49,25 @@ Instead of one color for every known word, maturity highlighting tints each know
**How it works:** **How it works:**
1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`, `is:new`) - no extra card data is downloaded. 1. During the known-word cache refresh, SubMiner classifies each note with Anki search filters (`prop:ivl`, `is:learn`) - no extra card data is downloaded.
2. Each note gets the tier of its **most mature** card: `mature` (interval ≥ threshold), `young` (in review below the threshold), `learning` (in (re)learning), or `new` (never reviewed). 2. Each note gets the tier of its **most mature** card: `mature` (in review, interval ≥ threshold), `young` (in review, interval below the threshold), `learning` (in the learning or relearning queue), or `new` (never studied). The buckets are disjoint, matching Anki's own card counts: a lapsed card in relearning counts as `learning`, not `young`, even though its interval is ≥ 1 day. A note with a mature card plus a relearning card still shows `mature`.
3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting. 3. A word matched by several notes takes the most mature tier among them, with the same reading-aware matching as regular known-word highlighting.
4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color. 4. Known tokens render in the tier color instead of `subtitleStyle.knownWordColor`; if tier data is missing for a match, the token falls back to the single known-word color.
**Key settings:** **Key settings:**
| Option | Default | Description | | Option | Default | Description |
| -------------------------------------------- | --------- | ---------------------------------------------------------- | | ------------------------------------------------ | --------- | --------------------------------------------------------------------- |
| `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) | | `ankiConnect.knownWords.maturityEnabled` | `false` | Color known words by card maturity (requires known-word highlighting) |
| `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature | | `ankiConnect.knownWords.matureThresholdDays` | `21` | Card interval in days at which a word counts as mature |
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards | | `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for (re)learning cards | | `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for cards in the learning/relearning queue |
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards | | `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards | | `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched. Changing `maturityEnabled` or the threshold triggers a full known-word cache refresh so tiers are refetched, as does upgrading to a build that revises the tier rules.
How often the `learning` color appears depends on your deck preset: with no relearning steps configured, a lapsed card returns straight to review and shows `young` instead.
While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature). While maturity highlighting is on, the session help color legend replaces its single "Known words" swatch with one row per tier (new, learning, young, mature).
@@ -98,7 +98,7 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
}; };
assert.equal( assert.equal(
getKnownWordCacheLifecycleConfig(enabled), getKnownWordCacheLifecycleConfig(enabled),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}', '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}',
); );
const customThreshold: AnkiConnectConfig = { const customThreshold: AnkiConnectConfig = {
@@ -111,7 +111,19 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
}; };
assert.equal( assert.equal(
getKnownWordCacheLifecycleConfig(customThreshold), getKnownWordCacheLifecycleConfig(customThreshold),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30}', '{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":30,"maturityRules":2}',
);
});
test('a cache built under the old tier rules is invalidated', () => {
const config: AnkiConnectConfig = {
knownWords: { highlightEnabled: true, maturityEnabled: true, refreshMinutes: 60 },
};
// v1 rules put lapsed cards in young because the interval queries did not
// exclude is:learn; those persisted tiers must not be served under v2.
assert.notEqual(
getKnownWordCacheLifecycleConfig(config),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
); );
}); });
@@ -120,8 +132,8 @@ test('refresh fetches tier sets and getKnownWordTier classifies notes', async ()
try { try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]); clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]);
clientState.notesInfoResult = [ clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } }, { noteId: 1, fields: { Word: { value: '猫' } } },
@@ -151,8 +163,8 @@ test('a note with cards in several tiers counts as its most mature card', async
try { try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [1]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
@@ -169,8 +181,8 @@ test('a word matched by several notes takes the most mature note tier', async ()
try { try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
clientState.notesInfoResult = [ clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } }, { noteId: 1, fields: { Word: { value: '猫' } } },
@@ -190,8 +202,8 @@ test('tiers are reading-aware for words with several readings', async () => {
try { try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
clientState.notesInfoResult = [ clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } }, { noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
@@ -216,8 +228,8 @@ test('reading-only fallback resolves tiers unless opted out', async () => {
try { try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []); clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
clientState.notesInfoResult = [ clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } }, { noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
@@ -265,7 +277,7 @@ test('refresh preserves known-word cache when maturity lookup fails', async () =
infoLogs.push(args.map((value) => String(value)).join(' ')); infoLogs.push(args.map((value) => String(value)).join(' '));
}; };
clientState.findNotesByQuery.set('deck:"Mining"', [1]); clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.failedQueries.add('deck:"Mining" prop:ivl>=21'); clientState.failedQueries.add('deck:"Mining" prop:ivl>=21 -is:learn');
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }]; clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
await manager.refresh(true); await manager.refresh(true);
@@ -294,8 +306,8 @@ test('tiers persist to v4 state and reload without refetching', async () => {
try { try {
Date.now = () => 120_000; Date.now = () => 120_000;
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]); clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
clientState.notesInfoResult = [ clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } }, { noteId: 1, fields: { Word: { value: '猫' } } },
@@ -348,8 +360,8 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => {
try { try {
clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]); clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [7]); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [7]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []); clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [8]); clientState.findNotesByQuery.set('deck:"Mining" is:learn', [8]);
clientState.notesInfoResult = [ clientState.notesInfoResult = [
{ noteId: 7, fields: { Word: { value: '猫' } } }, { noteId: 7, fields: { Word: { value: '猫' } } },
+6 -1
View File
@@ -7,6 +7,7 @@ import { AnkiConnectConfig } from '../types/anki';
import type { KnownWordMaturityTier } from '../types/subtitle'; import type { KnownWordMaturityTier } from '../types/subtitle';
import { createLogger } from '../logger'; import { createLogger } from '../logger';
import { import {
KNOWN_WORD_MATURITY_RULES_VERSION,
classifyKnownWordNoteTier, classifyKnownWordNoteTier,
fetchKnownWordMaturityTierSets, fetchKnownWordMaturityTierSets,
getKnownWordMaturityEnabled, getKnownWordMaturityEnabled,
@@ -76,10 +77,14 @@ export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): str
scope: getKnownWordCacheScopeForConfig(config), scope: getKnownWordCacheScopeForConfig(config),
fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '', fieldsWord: trimToNonEmptyString(config.fields?.word) ?? '',
}; };
// The maturity field is only added while enabled so persisted caches from // The maturity fields are only added while enabled so persisted caches from
// before the feature existed (or with it off) keep their identity. // before the feature existed (or with it off) keep their identity.
// maturityRules is the classification-rule version: bump it whenever the tier
// queries change meaning so existing caches refetch instead of serving tiers
// computed under the old rules.
if (getKnownWordMaturityEnabled(config)) { if (getKnownWordMaturityEnabled(config)) {
payload.maturity = getMatureIntervalThresholdDays(config); payload.maturity = getMatureIntervalThresholdDays(config);
payload.maturityRules = KNOWN_WORD_MATURITY_RULES_VERSION;
} }
return JSON.stringify(payload); return JSON.stringify(payload);
} }
@@ -46,15 +46,26 @@ test('mature threshold falls back to default for invalid values', () => {
test('tier queries append Anki search props to a deck scope query', () => { test('tier queries append Anki search props to a deck scope query', () => {
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21); const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21'); assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21 -is:learn');
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21'); assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn');
assert.equal(queries.learning, 'deck:"Mining" is:learn');
});
test('interval tiers exclude (re)learning cards so the buckets stay disjoint', () => {
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
// A lapsed card keeps an interval of at least the lapse minInt (>= 1), so
// without the exclusion the young query would claim every relearning card
// and the learning tier could only ever match brand-new cards mid-step.
for (const intervalQuery of [queries.mature, queries.young]) {
assert.ok(intervalQuery.includes('-is:learn'));
}
assert.equal(queries.learning, 'deck:"Mining" is:learn'); assert.equal(queries.learning, 'deck:"Mining" is:learn');
}); });
test('tier queries with an empty scope query have no leading space', () => { test('tier queries with an empty scope query have no leading space', () => {
const queries = buildKnownWordMaturityTierQueries('', 30); const queries = buildKnownWordMaturityTierQueries('', 30);
assert.equal(queries.mature, 'prop:ivl>=30'); assert.equal(queries.mature, 'prop:ivl>=30 -is:learn');
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30'); assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30 -is:learn');
assert.equal(queries.learning, 'is:learn'); assert.equal(queries.learning, 'is:learn');
}); });
+12 -2
View File
@@ -3,6 +3,10 @@ import type { KnownWordMaturityTier } from '../types/subtitle';
export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21; export const DEFAULT_MATURE_INTERVAL_THRESHOLD_DAYS = 21;
// Version of the tier classification rules; part of the known-word cache
// identity so a rule change invalidates caches built under the old rules.
export const KNOWN_WORD_MATURITY_RULES_VERSION = 2;
// Ascending maturity; index order backs tier comparison. // Ascending maturity; index order backs tier comparison.
const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature']; const TIER_ORDER: readonly KnownWordMaturityTier[] = ['new', 'learning', 'young', 'mature'];
@@ -37,14 +41,20 @@ export function getMatureIntervalThresholdDays(config: AnkiConnectConfig): numbe
// Anki search props classify notes server-side: a note matches a tier query // Anki search props classify notes server-side: a note matches a tier query
// when ANY of its cards matches, which implements most-mature-card-wins for // when ANY of its cards matches, which implements most-mature-card-wins for
// free once tiers are checked in mature > young > learning order. // free once tiers are checked in mature > young > learning order.
//
// The interval tiers exclude is:learn so the per-card buckets stay disjoint and
// match Anki's own card counts, where (re)learning is its own bucket rather
// than part of young/mature. Without the exclusion a lapsed card - whose
// interval is reset to at least lapse minInt, so >= 1 - is caught by the young
// query first and the learning tier becomes unreachable in practice.
export function buildKnownWordMaturityTierQueries( export function buildKnownWordMaturityTierQueries(
scopeQuery: string, scopeQuery: string,
thresholdDays: number, thresholdDays: number,
): KnownWordMaturityTierQueries { ): KnownWordMaturityTierQueries {
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : ''; const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
return { return {
mature: `${prefix}prop:ivl>=${thresholdDays}`, mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`,
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`, young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`,
learning: `${prefix}is:learn`, learning: `${prefix}is:learn`,
}; };
} }