feat: unified use-type-aware practice scoring (#5703)
* feat: unified use-type-aware practice scoring on ConstructUses - Add practiceScore() and practiceTier to ConstructUses for shared scoring across message practice and standalone practice - Add isChatUse, isAssistedChatUse, isIncorrectPractice getters to ConstructUseTypeEnum with exhaustive switches - Add PracticeTier enum (suppressed/active/maintenance) - Wire into PracticeSelectionRepo and AnalyticsPracticeSessionRepo - 28 unit tests covering tier classification, scoring, and ordering Closes #5700 * formatting, fix linting issue * move some stuff around --------- Co-authored-by: ggurdin <ggurdin@gmail.com>
This commit is contained in:
parent
c2472bd2a4
commit
8a3979c61b
8 changed files with 635 additions and 31 deletions
|
|
@ -14,4 +14,16 @@ class AnalyticsConstants {
|
|||
static const levelUpImageFileName = "LvL_Up_Full_Banner.png";
|
||||
static const vocabIconFileName = "Vocabulary_icon.png";
|
||||
static const morphIconFileName = "grammar_icon.png";
|
||||
|
||||
/// Default days-since-last-used when a construct has never been practiced.
|
||||
static const int defaultDaysSinceLastUsed = 20;
|
||||
|
||||
/// Multiplier for content words (nouns, verbs, adjectives).
|
||||
static const int contentWordMultiplier = 10;
|
||||
|
||||
/// Multiplier for function words (articles, prepositions).
|
||||
static const int functionWordMultiplier = 7;
|
||||
|
||||
/// Bonus multiplier applied to active-tier constructs.
|
||||
static const int activeTierMultiplier = 2;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
|
|||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/practice_tier_enum.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
|
||||
/// One lemma and a list of construct uses for that lemma
|
||||
class ConstructUses {
|
||||
|
|
@ -98,9 +100,84 @@ class ConstructUses {
|
|||
return result;
|
||||
}
|
||||
|
||||
/// Read-only view of all uses, sorted chronologically (oldest first).
|
||||
List<OneConstructUse> get uses => List.unmodifiable(_uses);
|
||||
|
||||
/// Classify this construct into a [PracticeTier] based on use-type history.
|
||||
///
|
||||
/// Walks uses in reverse chronological order to find the most recent
|
||||
/// chat use and any incorrect practice answers after it.
|
||||
PracticeTier get practiceTier {
|
||||
// Walk reverse chronologically. Everything seen before finding the
|
||||
// last chat use is more recent than that chat use.
|
||||
bool hasIncorrectAfterLastChatUse = false;
|
||||
|
||||
for (int i = _uses.length - 1; i >= 0; i--) {
|
||||
final use = _uses[i];
|
||||
|
||||
if (use.useType.isChatUse) {
|
||||
// Found the most recent chat use.
|
||||
if (use.useType == ConstructUseTypeEnum.wa &&
|
||||
!hasIncorrectAfterLastChatUse) {
|
||||
return PracticeTier.suppressed;
|
||||
}
|
||||
if (use.useType.isAssistedChatUse) {
|
||||
return PracticeTier.active;
|
||||
}
|
||||
// wa with incorrect after → active
|
||||
if (hasIncorrectAfterLastChatUse) {
|
||||
return PracticeTier.active;
|
||||
}
|
||||
return PracticeTier.maintenance;
|
||||
}
|
||||
|
||||
if (use.useType.isIncorrectPractice) {
|
||||
hasIncorrectAfterLastChatUse = true;
|
||||
}
|
||||
}
|
||||
|
||||
// No chat use found (only practice history).
|
||||
if (hasIncorrectAfterLastChatUse) return PracticeTier.active;
|
||||
return PracticeTier.maintenance;
|
||||
}
|
||||
|
||||
DateTime? lastUseByTypes(List<ConstructUseTypeEnum> types) =>
|
||||
_uses.lastWhereOrNull((u) => types.contains(u.useType))?.timeStamp;
|
||||
|
||||
/// Compute priority score for this construct.
|
||||
///
|
||||
/// Higher score = higher priority (should be practiced sooner).
|
||||
/// Suppressed-tier constructs return 0.
|
||||
///
|
||||
/// When [activityType] is provided, recency is checked against that
|
||||
/// activity's specific use types (e.g., corPA/incPA for wordMeaning).
|
||||
/// Otherwise, aggregate recency across all use types is used.
|
||||
int practiceScore({ActivityTypeEnum? activityType}) {
|
||||
final tier = practiceTier;
|
||||
if (tier == PracticeTier.suppressed) return 0;
|
||||
|
||||
// Per-activity-type recency when available, otherwise aggregate.
|
||||
final DateTime? lastUsedDate = activityType != null
|
||||
? lastUseByTypes(activityType.associatedUseTypes)
|
||||
: lastUsed;
|
||||
|
||||
final daysSince = lastUsedDate == null
|
||||
? AnalyticsConstants.defaultDaysSinceLastUsed
|
||||
: DateTime.now().difference(lastUsedDate).inDays;
|
||||
|
||||
final wordMultiplier = id.isContentWord
|
||||
? AnalyticsConstants.contentWordMultiplier
|
||||
: AnalyticsConstants.functionWordMultiplier;
|
||||
|
||||
var score = daysSince * wordMultiplier;
|
||||
|
||||
if (tier == PracticeTier.active) {
|
||||
score *= AnalyticsConstants.activeTierMultiplier;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = {
|
||||
'construct_id': id.toJson(),
|
||||
|
|
|
|||
|
|
@ -462,6 +462,50 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this use type represents direct chat production (wa, ga, ta).
|
||||
bool get isChatUse {
|
||||
switch (this) {
|
||||
case ConstructUseTypeEnum.wa:
|
||||
case ConstructUseTypeEnum.ga:
|
||||
case ConstructUseTypeEnum.ta:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this chat use involved assistance (ga = IGC, ta = IT).
|
||||
bool get isAssistedChatUse {
|
||||
switch (this) {
|
||||
case ConstructUseTypeEnum.ga:
|
||||
case ConstructUseTypeEnum.ta:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this is an incorrect answer in any practice activity.
|
||||
bool get isIncorrectPractice {
|
||||
switch (this) {
|
||||
case ConstructUseTypeEnum.incPA:
|
||||
case ConstructUseTypeEnum.incWL:
|
||||
case ConstructUseTypeEnum.incHWL:
|
||||
case ConstructUseTypeEnum.incL:
|
||||
case ConstructUseTypeEnum.incM:
|
||||
case ConstructUseTypeEnum.incMM:
|
||||
case ConstructUseTypeEnum.incIt:
|
||||
case ConstructUseTypeEnum.incIGC:
|
||||
case ConstructUseTypeEnum.incLM:
|
||||
case ConstructUseTypeEnum.incLA:
|
||||
case ConstructUseTypeEnum.incGC:
|
||||
case ConstructUseTypeEnum.incGE:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ConstructUseTypeUtil {
|
||||
|
|
|
|||
14
lib/pangea/analytics_misc/practice_tier_enum.dart
Normal file
14
lib/pangea/analytics_misc/practice_tier_enum.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/// Priority tier for spaced repetition scoring.
|
||||
///
|
||||
/// Tier is determined by how the user most recently encountered the word
|
||||
/// in chat (wa/ga/ta) and whether they've struggled with it in practice.
|
||||
enum PracticeTier {
|
||||
/// User demonstrated mastery (wa) with no subsequent errors — skip entirely.
|
||||
suppressed,
|
||||
|
||||
/// User needed help (ta/ga) or recently got it wrong — prioritize.
|
||||
active,
|
||||
|
||||
/// Standard aging-based priority (correctly practiced, but aging).
|
||||
maintenance,
|
||||
}
|
||||
|
|
@ -127,14 +127,16 @@ class AnalyticsPracticeSessionRepo {
|
|||
.getAggregatedConstructs(ConstructTypeEnum.vocab, language)
|
||||
.then((map) => map.values.toList());
|
||||
|
||||
// sort by last used descending, nulls first
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
constructs.sort((a, b) {
|
||||
final dateA = a.lastUsed;
|
||||
final dateB = b.lastUsed;
|
||||
if (dateA == null && dateB == null) return 0;
|
||||
if (dateA == null) return -1;
|
||||
if (dateB == null) return 1;
|
||||
return dateA.compareTo(dateB);
|
||||
final scoreA = a.practiceScore(
|
||||
activityType: ActivityTypeEnum.lemmaMeaning,
|
||||
);
|
||||
final scoreB = b.practiceScore(
|
||||
activityType: ActivityTypeEnum.lemmaMeaning,
|
||||
);
|
||||
return scoreB.compareTo(scoreA);
|
||||
});
|
||||
|
||||
final Set<String> seemLemmas = {};
|
||||
|
|
@ -162,14 +164,12 @@ class AnalyticsPracticeSessionRepo {
|
|||
.getAggregatedConstructs(ConstructTypeEnum.vocab, language)
|
||||
.then((map) => map.values.toList());
|
||||
|
||||
// sort by last used descending, nulls first
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
constructs.sort((a, b) {
|
||||
final dateA = a.lastUsed;
|
||||
final dateB = b.lastUsed;
|
||||
if (dateA == null && dateB == null) return 0;
|
||||
if (dateA == null) return -1;
|
||||
if (dateB == null) return 1;
|
||||
return dateA.compareTo(dateB);
|
||||
final scoreA = a.practiceScore(activityType: ActivityTypeEnum.lemmaAudio);
|
||||
final scoreB = b.practiceScore(activityType: ActivityTypeEnum.lemmaAudio);
|
||||
return scoreB.compareTo(scoreA);
|
||||
});
|
||||
|
||||
final Set<String> seenLemmas = {};
|
||||
|
|
@ -246,14 +246,16 @@ class AnalyticsPracticeSessionRepo {
|
|||
}
|
||||
}
|
||||
|
||||
// sort by last used descending, nulls first
|
||||
// Score and sort by priority (highest first). Uses shared scorer for
|
||||
// consistent prioritization with message practice.
|
||||
constructs.sort((a, b) {
|
||||
final dateA = a.lastUsed;
|
||||
final dateB = b.lastUsed;
|
||||
if (dateA == null && dateB == null) return 0;
|
||||
if (dateA == null) return -1;
|
||||
if (dateB == null) return 1;
|
||||
return dateA.compareTo(dateB);
|
||||
final scoreA = a.practiceScore(
|
||||
activityType: ActivityTypeEnum.grammarCategory,
|
||||
);
|
||||
final scoreB = b.practiceScore(
|
||||
activityType: ActivityTypeEnum.grammarCategory,
|
||||
);
|
||||
return scoreB.compareTo(scoreA);
|
||||
});
|
||||
|
||||
final targets = <MorphPracticeTarget>[];
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import 'package:collection/collection.dart';
|
|||
import 'package:matrix/matrix.dart' hide Result;
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/user_lemma_info_extension.dart';
|
||||
|
|
@ -182,4 +183,11 @@ class ConstructIdentifier {
|
|||
text: PangeaTokenText.fromString(lemma),
|
||||
morph: {},
|
||||
);
|
||||
|
||||
/// Score for a construct that has never been seen (no use history).
|
||||
int get unseenPracticeScore =>
|
||||
AnalyticsConstants.defaultDaysSinceLastUsed *
|
||||
(isContentWord
|
||||
? AnalyticsConstants.contentWordMultiplier
|
||||
: AnalyticsConstants.functionWordMultiplier);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,17 +239,10 @@ class PracticeSelectionRepo {
|
|||
.getConstructUses(ids, language);
|
||||
|
||||
for (final token in tokens) {
|
||||
final construct = constructs[idMap[token]];
|
||||
final lastUsed = construct?.lastUseByTypes(
|
||||
activityType.associatedUseTypes,
|
||||
);
|
||||
|
||||
final daysSinceLastUsed = lastUsed == null
|
||||
? 20
|
||||
: DateTime.now().difference(lastUsed).inDays;
|
||||
|
||||
final id = idMap[token]!;
|
||||
scores[token] =
|
||||
daysSinceLastUsed * (token.vocabConstructID.isContentWord ? 10 : 7);
|
||||
constructs[id]?.practiceScore(activityType: activityType) ??
|
||||
id.unseenPracticeScore;
|
||||
}
|
||||
return scores;
|
||||
}
|
||||
|
|
|
|||
454
test/pangea/practice_target_scorer_test.dart
Normal file
454
test/pangea/practice_target_scorer_test.dart
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/practice_tier_enum.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
|
||||
/// Helper to create a [OneConstructUse] with minimal required fields.
|
||||
OneConstructUse _makeUse(
|
||||
ConstructUseTypeEnum type, {
|
||||
DateTime? time,
|
||||
String lemma = 'test',
|
||||
String category = 'verb',
|
||||
int? xp,
|
||||
}) {
|
||||
return OneConstructUse(
|
||||
useType: type,
|
||||
lemma: lemma,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: ConstructUseMetaData(
|
||||
roomId: null,
|
||||
timeStamp: time ?? DateTime.now(),
|
||||
),
|
||||
category: category,
|
||||
form: lemma,
|
||||
xp: xp ?? type.pointValue,
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to create a [ConstructUses] wrapper.
|
||||
ConstructUses _makeConstructUses(
|
||||
List<OneConstructUse> uses, {
|
||||
String lemma = 'test',
|
||||
String category = 'verb',
|
||||
}) {
|
||||
return ConstructUses(
|
||||
uses: uses,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
lemma: lemma,
|
||||
category: category,
|
||||
);
|
||||
}
|
||||
|
||||
ConstructIdentifier _makeId({String lemma = 'test', String category = 'verb'}) {
|
||||
return ConstructIdentifier(
|
||||
lemma: lemma,
|
||||
type: ConstructTypeEnum.vocab,
|
||||
category: category,
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('ConstructUseTypeEnum boolean getters', () {
|
||||
test('isChatUse is true for wa, ga, ta', () {
|
||||
expect(ConstructUseTypeEnum.wa.isChatUse, true);
|
||||
expect(ConstructUseTypeEnum.ga.isChatUse, true);
|
||||
expect(ConstructUseTypeEnum.ta.isChatUse, true);
|
||||
expect(ConstructUseTypeEnum.corPA.isChatUse, false);
|
||||
expect(ConstructUseTypeEnum.incLM.isChatUse, false);
|
||||
expect(ConstructUseTypeEnum.click.isChatUse, false);
|
||||
});
|
||||
|
||||
test('isAssistedChatUse is true for ga, ta only', () {
|
||||
expect(ConstructUseTypeEnum.ga.isAssistedChatUse, true);
|
||||
expect(ConstructUseTypeEnum.ta.isAssistedChatUse, true);
|
||||
expect(ConstructUseTypeEnum.wa.isAssistedChatUse, false);
|
||||
});
|
||||
|
||||
test('isIncorrectPractice covers all inc* types', () {
|
||||
expect(ConstructUseTypeEnum.incPA.isIncorrectPractice, true);
|
||||
expect(ConstructUseTypeEnum.incWL.isIncorrectPractice, true);
|
||||
expect(ConstructUseTypeEnum.incLM.isIncorrectPractice, true);
|
||||
expect(ConstructUseTypeEnum.incGE.isIncorrectPractice, true);
|
||||
expect(ConstructUseTypeEnum.corPA.isIncorrectPractice, false);
|
||||
expect(ConstructUseTypeEnum.wa.isIncorrectPractice, false);
|
||||
expect(ConstructUseTypeEnum.click.isIncorrectPractice, false);
|
||||
});
|
||||
});
|
||||
|
||||
group('ConstructUses.practiceTier classification', () {
|
||||
test('null uses treated as maintenance by scorer', () {
|
||||
// null has no .practiceTier — scorer handles it
|
||||
final id = _makeId(category: 'verb');
|
||||
final score = id.unseenPracticeScore;
|
||||
expect(score, greaterThan(0)); // maintenance, not suppressed
|
||||
});
|
||||
|
||||
test('empty uses → maintenance', () {
|
||||
final uses = _makeConstructUses([]);
|
||||
expect(uses.practiceTier, PracticeTier.maintenance);
|
||||
});
|
||||
|
||||
test('only wa use → suppressed', () {
|
||||
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.wa)]);
|
||||
expect(uses.practiceTier, PracticeTier.suppressed);
|
||||
});
|
||||
|
||||
test('wa then correct practice → suppressed (no incorrect)', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.wa,
|
||||
time: now.subtract(const Duration(days: 5)),
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.corLM,
|
||||
time: now.subtract(const Duration(days: 3)),
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.corPA,
|
||||
time: now.subtract(const Duration(days: 1)),
|
||||
),
|
||||
]);
|
||||
expect(uses.practiceTier, PracticeTier.suppressed);
|
||||
});
|
||||
|
||||
test('wa then incorrect practice → active', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.wa,
|
||||
time: now.subtract(const Duration(days: 5)),
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.incLM,
|
||||
time: now.subtract(const Duration(days: 2)),
|
||||
),
|
||||
]);
|
||||
expect(uses.practiceTier, PracticeTier.active);
|
||||
});
|
||||
|
||||
test('ga use (IGC correction) → active', () {
|
||||
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.ga)]);
|
||||
expect(uses.practiceTier, PracticeTier.active);
|
||||
});
|
||||
|
||||
test('ta use (IT translation) → active', () {
|
||||
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.ta)]);
|
||||
expect(uses.practiceTier, PracticeTier.active);
|
||||
});
|
||||
|
||||
test('ga then wa → suppressed (wa is most recent chat use)', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.ga,
|
||||
time: now.subtract(const Duration(days: 10)),
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.wa,
|
||||
time: now.subtract(const Duration(days: 1)),
|
||||
),
|
||||
]);
|
||||
expect(uses.practiceTier, PracticeTier.suppressed);
|
||||
});
|
||||
|
||||
test('wa then ga → active (ga is most recent chat use)', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.wa,
|
||||
time: now.subtract(const Duration(days: 10)),
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.ga,
|
||||
time: now.subtract(const Duration(days: 1)),
|
||||
),
|
||||
]);
|
||||
expect(uses.practiceTier, PracticeTier.active);
|
||||
});
|
||||
|
||||
test('only correct practice (no chat uses) → maintenance', () {
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.corPA),
|
||||
_makeUse(ConstructUseTypeEnum.corLM),
|
||||
]);
|
||||
expect(uses.practiceTier, PracticeTier.maintenance);
|
||||
});
|
||||
|
||||
test('only incorrect practice (no chat uses) → active', () {
|
||||
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.incPA)]);
|
||||
expect(uses.practiceTier, PracticeTier.active);
|
||||
});
|
||||
|
||||
test('click use only → maintenance', () {
|
||||
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.click)]);
|
||||
expect(uses.practiceTier, PracticeTier.maintenance);
|
||||
});
|
||||
|
||||
test('wa → corPA → incLM → active (incorrect after wa)', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.wa,
|
||||
time: now.subtract(const Duration(days: 10)),
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.corPA,
|
||||
time: now.subtract(const Duration(days: 5)),
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.incLM,
|
||||
time: now.subtract(const Duration(days: 1)),
|
||||
),
|
||||
]);
|
||||
expect(uses.practiceTier, PracticeTier.active);
|
||||
});
|
||||
});
|
||||
|
||||
group('scoreConstruct', () {
|
||||
test('suppressed tier returns 0', () {
|
||||
final uses = _makeConstructUses([_makeUse(ConstructUseTypeEnum.wa)]);
|
||||
expect(uses.practiceScore(), 0);
|
||||
});
|
||||
|
||||
test(
|
||||
'null uses (never seen) returns default days × content multiplier',
|
||||
() {
|
||||
final contentId = _makeId(category: 'verb');
|
||||
final score = contentId.unseenPracticeScore;
|
||||
// 20 days × 10 (content word) = 200
|
||||
expect(score, 200);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'null uses with function word → default days × function multiplier',
|
||||
() {
|
||||
final funcId = _makeId(category: 'det');
|
||||
final score = funcId.unseenPracticeScore;
|
||||
// 20 days × 7 (function word) = 140
|
||||
expect(score, 140);
|
||||
},
|
||||
);
|
||||
|
||||
test('active tier gets 2× multiplier', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.ga, time: now),
|
||||
]);
|
||||
final score = uses.practiceScore(
|
||||
activityType: ActivityTypeEnum.lemmaMeaning,
|
||||
);
|
||||
// ga is most recent chat use → active tier.
|
||||
// No lemmaMeaning uses → defaults to 20 days.
|
||||
// 20 × 10 (content) × 2 (active) = 400.
|
||||
expect(score, 400);
|
||||
});
|
||||
|
||||
test('maintenance tier, recent use → low score', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.corPA, time: now),
|
||||
]);
|
||||
final score = uses.practiceScore(
|
||||
activityType: ActivityTypeEnum.wordMeaning,
|
||||
);
|
||||
// corPA matches wordMeaning's associatedUseTypes.
|
||||
// 0 days × 10 (content) = 0. Maintenance (no chat uses).
|
||||
expect(score, 0);
|
||||
});
|
||||
|
||||
test('per-activity-type recency filters correctly', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.corLM,
|
||||
time: now.subtract(const Duration(days: 1)),
|
||||
),
|
||||
_makeUse(ConstructUseTypeEnum.corPA, time: now),
|
||||
], category: 'noun');
|
||||
// Score for lemmaMeaning: corLM was 1 day ago → 1 × 10 = 10
|
||||
final lmScore = uses.practiceScore(
|
||||
activityType: ActivityTypeEnum.lemmaMeaning,
|
||||
);
|
||||
// Score for wordMeaning: corPA was today → 0 × 10 = 0
|
||||
final wmScore = uses.practiceScore(
|
||||
activityType: ActivityTypeEnum.wordMeaning,
|
||||
);
|
||||
expect(lmScore, greaterThan(wmScore));
|
||||
});
|
||||
|
||||
test('no activityType uses aggregate lastUsed', () {
|
||||
final now = DateTime.now();
|
||||
final uses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.click,
|
||||
time: now.subtract(const Duration(days: 3)),
|
||||
),
|
||||
]);
|
||||
final score = uses.practiceScore();
|
||||
// No activityType → uses aggregate lastUsed (3 days ago).
|
||||
// 3 × 10 (content) = 30.
|
||||
expect(score, 30);
|
||||
});
|
||||
});
|
||||
|
||||
group('scoring ordering', () {
|
||||
test('active-tier words rank above same-age maintenance words', () {
|
||||
final now = DateTime.now();
|
||||
final fiveDaysAgo = now.subtract(const Duration(days: 5));
|
||||
|
||||
// Active: ga use 5 days ago
|
||||
final activeUses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.ga, time: fiveDaysAgo),
|
||||
]);
|
||||
|
||||
// Maintenance: click 5 days ago (no chat use → maintenance)
|
||||
final maintenanceUses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.click, time: fiveDaysAgo),
|
||||
]);
|
||||
|
||||
final activeScore = activeUses.practiceScore();
|
||||
final maintenanceScore = maintenanceUses.practiceScore();
|
||||
|
||||
expect(activeScore, greaterThan(maintenanceScore));
|
||||
});
|
||||
|
||||
test('content words rank above function words at same recency', () {
|
||||
final now = DateTime.now();
|
||||
final threeDaysAgo = now.subtract(const Duration(days: 3));
|
||||
|
||||
final contentUses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.click, time: threeDaysAgo),
|
||||
], category: 'verb');
|
||||
final funcUses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.click, time: threeDaysAgo),
|
||||
], category: 'det');
|
||||
|
||||
final contentScore = contentUses.practiceScore();
|
||||
final funcScore = funcUses.practiceScore();
|
||||
|
||||
// 3 × 10 = 30 vs 3 × 7 = 21
|
||||
expect(contentScore, greaterThan(funcScore));
|
||||
});
|
||||
|
||||
test('older words rank above recent words in same tier', () {
|
||||
final now = DateTime.now();
|
||||
|
||||
final oldUses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.click,
|
||||
time: now.subtract(const Duration(days: 10)),
|
||||
),
|
||||
], category: 'noun');
|
||||
|
||||
final recentUses = _makeConstructUses([
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.click,
|
||||
time: now.subtract(const Duration(days: 2)),
|
||||
),
|
||||
], category: 'noun');
|
||||
|
||||
final oldScore = oldUses.practiceScore();
|
||||
final recentScore = recentUses.practiceScore();
|
||||
|
||||
expect(oldScore, greaterThan(recentScore));
|
||||
});
|
||||
|
||||
test('suppressed words always rank last (score 0)', () {
|
||||
final id = _makeId(category: 'verb');
|
||||
final suppressedUses = _makeConstructUses([
|
||||
_makeUse(ConstructUseTypeEnum.wa),
|
||||
]);
|
||||
|
||||
final neverSeenScore = id.unseenPracticeScore;
|
||||
final suppressedScore = suppressedUses.practiceScore();
|
||||
|
||||
expect(suppressedScore, 0);
|
||||
expect(neverSeenScore, greaterThan(suppressedScore));
|
||||
});
|
||||
});
|
||||
|
||||
group('design scenario from instructions', () {
|
||||
test(
|
||||
'full lifecycle: wa → suppressed, ta → active, inc → stays active, cors → maintenance',
|
||||
() {
|
||||
final now = DateTime.now();
|
||||
|
||||
// Step 1: User types "gato" without assistance → wa → suppressed
|
||||
final step1 = _makeConstructUses(
|
||||
[
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.wa,
|
||||
time: now.subtract(const Duration(days: 20)),
|
||||
lemma: 'gato',
|
||||
),
|
||||
],
|
||||
lemma: 'gato',
|
||||
category: 'noun',
|
||||
);
|
||||
expect(step1.practiceTier, PracticeTier.suppressed);
|
||||
expect(step1.practiceScore(), 0);
|
||||
|
||||
// Step 2: User uses IT for "mariposa" → ta → active
|
||||
final step2 = _makeConstructUses(
|
||||
[
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.ta,
|
||||
time: now.subtract(const Duration(days: 15)),
|
||||
lemma: 'mariposa',
|
||||
),
|
||||
],
|
||||
lemma: 'mariposa',
|
||||
category: 'noun',
|
||||
);
|
||||
expect(step2.practiceTier, PracticeTier.active);
|
||||
expect(step2.practiceScore(), greaterThan(0));
|
||||
|
||||
// Step 3: User gets "mariposa" wrong → incLM → stays active
|
||||
final step3 = _makeConstructUses(
|
||||
[
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.ta,
|
||||
time: now.subtract(const Duration(days: 15)),
|
||||
lemma: 'mariposa',
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.incLM,
|
||||
time: now.subtract(const Duration(days: 10)),
|
||||
lemma: 'mariposa',
|
||||
),
|
||||
],
|
||||
lemma: 'mariposa',
|
||||
category: 'noun',
|
||||
);
|
||||
expect(step3.practiceTier, PracticeTier.active);
|
||||
|
||||
// Step 6: User misspells "gato" and IGC corrects → ga → moves from suppressed to active
|
||||
final step6 = _makeConstructUses(
|
||||
[
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.wa,
|
||||
time: now.subtract(const Duration(days: 20)),
|
||||
lemma: 'gato',
|
||||
),
|
||||
_makeUse(
|
||||
ConstructUseTypeEnum.ga,
|
||||
time: now.subtract(const Duration(days: 1)),
|
||||
lemma: 'gato',
|
||||
),
|
||||
],
|
||||
lemma: 'gato',
|
||||
category: 'noun',
|
||||
);
|
||||
expect(step6.practiceTier, PracticeTier.active);
|
||||
expect(step6.practiceScore(), greaterThan(0));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue