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:
wcjord 2026-02-16 12:13:46 -05:00 committed by GitHub
parent c2472bd2a4
commit 8a3979c61b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 635 additions and 31 deletions

View file

@ -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;
}

View file

@ -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(),

View file

@ -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 {

View 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,
}

View file

@ -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>[];

View file

@ -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);
}

View file

@ -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;
}

View 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));
},
);
});
}