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
- 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.
+13 -11
View File
@@ -49,23 +49,25 @@ Instead of one color for every known word, maturity highlighting tints each know
**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.
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).
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` (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.
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:**
| Option | Default | Description |
| -------------------------------------------- | --------- | ---------------------------------------------------------- |
| `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 |
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed cards |
| `subtitleStyle.knownWordMaturityColors.learning` | `#b7bdf8` | Tier color for (re)learning cards |
| `subtitleStyle.knownWordMaturityColors.young` | `#91d7e3` | Tier color for young review cards |
| `subtitleStyle.knownWordMaturityColors.mature` | `#a6da95` | Tier color for mature cards |
| Option | Default | Description |
| ------------------------------------------------ | --------- | --------------------------------------------------------------------- |
| `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 |
| `subtitleStyle.knownWordMaturityColors.new` | `#ee99a0` | Tier color for never-reviewed 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.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).
@@ -98,7 +98,7 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
};
assert.equal(
getKnownWordCacheLifecycleConfig(enabled),
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21}',
'{"refreshMinutes":60,"scope":"all","fieldsWord":"","maturity":21,"maturityRules":2}',
);
const customThreshold: AnkiConnectConfig = {
@@ -111,7 +111,19 @@ test('lifecycle config key is unchanged when maturity is disabled', () => {
};
assert.equal(
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 {
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>=1 prop:ivl<21', [2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]);
clientState.notesInfoResult = [
{ 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 {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 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 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
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 {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', [2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
@@ -190,8 +202,8 @@ test('tiers are reading-aware for words with several readings', async () => {
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
@@ -216,8 +228,8 @@ test('reading-only fallback resolves tiers unless opted out', async () => {
try {
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
clientState.notesInfoResult = [
{ 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(' '));
};
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: '猫' } } }];
await manager.refresh(true);
@@ -294,8 +306,8 @@ test('tiers persist to v4 state and reload without refetching', async () => {
try {
Date.now = () => 120_000;
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
clientState.notesInfoResult = [
{ noteId: 1, fields: { Word: { value: '猫' } } },
@@ -348,8 +360,8 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => {
try {
clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21', [7]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21', []);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [7]);
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [8]);
clientState.notesInfoResult = [
{ 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 { createLogger } from '../logger';
import {
KNOWN_WORD_MATURITY_RULES_VERSION,
classifyKnownWordNoteTier,
fetchKnownWordMaturityTierSets,
getKnownWordMaturityEnabled,
@@ -76,10 +77,14 @@ export function getKnownWordCacheLifecycleConfig(config: AnkiConnectConfig): str
scope: getKnownWordCacheScopeForConfig(config),
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.
// 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)) {
payload.maturity = getMatureIntervalThresholdDays(config);
payload.maturityRules = KNOWN_WORD_MATURITY_RULES_VERSION;
}
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', () => {
const queries = buildKnownWordMaturityTierQueries('deck:"Mining"', 21);
assert.equal(queries.mature, 'deck:"Mining" prop:ivl>=21');
assert.equal(queries.young, 'deck:"Mining" prop:ivl>=1 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 -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');
});
test('tier queries with an empty scope query have no leading space', () => {
const queries = buildKnownWordMaturityTierQueries('', 30);
assert.equal(queries.mature, 'prop:ivl>=30');
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30');
assert.equal(queries.mature, 'prop:ivl>=30 -is:learn');
assert.equal(queries.young, 'prop:ivl>=1 prop:ivl<30 -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;
// 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.
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
// when ANY of its cards matches, which implements most-mature-card-wins for
// 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(
scopeQuery: string,
thresholdDays: number,
): KnownWordMaturityTierQueries {
const prefix = scopeQuery.trim().length > 0 ? `${scopeQuery.trim()} ` : '';
return {
mature: `${prefix}prop:ivl>=${thresholdDays}`,
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays}`,
mature: `${prefix}prop:ivl>=${thresholdDays} -is:learn`,
young: `${prefix}prop:ivl>=1 prop:ivl<${thresholdDays} -is:learn`,
learning: `${prefix}is:learn`,
};
}