merge with practice_engine
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import '../models/question.dart';
|
||||
import '../models/deck_config.dart';
|
||||
|
||||
/// Manages priority point calculations for questions.
|
||||
class PriorityManager {
|
||||
/// Applies priority changes based on answer correctness.
|
||||
static Question applyAnswerResult({
|
||||
required Question question,
|
||||
required bool isCorrect,
|
||||
required DeckConfig config,
|
||||
}) {
|
||||
if (isCorrect) {
|
||||
final newPriority = (question.priorityPoints -
|
||||
config.priorityDecreaseOnCorrect)
|
||||
.clamp(0, double.infinity)
|
||||
.toInt();
|
||||
return question.withPriorityPoints(newPriority);
|
||||
} else {
|
||||
return question.copyWith(
|
||||
priorityPoints: question.priorityPoints +
|
||||
config.priorityIncreaseOnIncorrect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets priority to 0.
|
||||
static Question resetPriority(Question question) {
|
||||
return question.withPriorityPoints(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import '../models/question.dart';
|
||||
|
||||
/// Handles spaced repetition logic for known questions.
|
||||
class SpacedRepetition {
|
||||
/// Base probability for known questions (very low).
|
||||
static const double baseKnownProbability = 0.01;
|
||||
|
||||
/// Maximum probability for known questions.
|
||||
static const double maxKnownProbability = 0.15;
|
||||
|
||||
/// Calculates the probability weight for a known question based on
|
||||
/// how long it's been since it was last seen.
|
||||
///
|
||||
/// [lastAttemptIndex] is the last attempt where the question was seen.
|
||||
/// [currentAttemptIndex] is the current global attempt index.
|
||||
static double calculateKnownQuestionWeight({
|
||||
required int lastAttemptIndex,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (lastAttemptIndex < 0) {
|
||||
// Never seen before, use base probability
|
||||
return baseKnownProbability;
|
||||
}
|
||||
|
||||
final attemptsSinceLastSeen = currentAttemptIndex - lastAttemptIndex;
|
||||
|
||||
// Probability increases with attempts since last seen
|
||||
// Formula: base + (min(attemptsSinceLastSeen / 20, 1) * (max - base))
|
||||
final progress = (attemptsSinceLastSeen / 20.0).clamp(0.0, 1.0);
|
||||
final probability = baseKnownProbability +
|
||||
(progress * (maxKnownProbability - baseKnownProbability));
|
||||
|
||||
return probability;
|
||||
}
|
||||
|
||||
/// Gets the weight for a question considering its known status and
|
||||
/// last attempt index.
|
||||
static double getQuestionWeight({
|
||||
required Question question,
|
||||
required int currentAttemptIndex,
|
||||
required double basePriorityWeight,
|
||||
}) {
|
||||
if (question.isKnown) {
|
||||
// Known questions use spaced repetition probability
|
||||
return calculateKnownQuestionWeight(
|
||||
lastAttemptIndex: question.lastAttemptIndex,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
) * basePriorityWeight;
|
||||
} else {
|
||||
// Unknown questions use priority-based weight
|
||||
// Priority points + 1 to ensure non-zero weight
|
||||
return (question.priorityPoints + 1).toDouble();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import 'dart:math';
|
||||
import '../models/question.dart';
|
||||
import 'spaced_repetition.dart';
|
||||
|
||||
/// Selects questions using weighted random selection.
|
||||
class WeightedSelector {
|
||||
final Random _random;
|
||||
|
||||
/// Creates a weighted selector with an optional random seed.
|
||||
WeightedSelector({int? seed}) : _random = Random(seed);
|
||||
|
||||
/// Selects [count] questions from [candidates] using weighted random selection.
|
||||
///
|
||||
/// [currentAttemptIndex] is used for spaced repetition calculations.
|
||||
/// Returns a list of selected questions (no duplicates).
|
||||
List<Question> selectQuestions({
|
||||
required List<Question> candidates,
|
||||
required int count,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (candidates.isEmpty || count <= 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (count >= candidates.length) {
|
||||
return List.from(candidates);
|
||||
}
|
||||
|
||||
final selected = <Question>[];
|
||||
final available = List<Question>.from(candidates);
|
||||
|
||||
while (selected.length < count && available.isNotEmpty) {
|
||||
final question = _selectOne(
|
||||
candidates: available,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
selected.add(question);
|
||||
available.remove(question);
|
||||
}
|
||||
|
||||
return selected;
|
||||
}
|
||||
|
||||
/// Selects a single question using weighted random selection.
|
||||
Question _selectOne({
|
||||
required List<Question> candidates,
|
||||
required int currentAttemptIndex,
|
||||
}) {
|
||||
if (candidates.length == 1) {
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
// Calculate weights for all candidates
|
||||
final weights = candidates.map((q) {
|
||||
if (q.isKnown) {
|
||||
return SpacedRepetition.calculateKnownQuestionWeight(
|
||||
lastAttemptIndex: q.lastAttemptIndex,
|
||||
currentAttemptIndex: currentAttemptIndex,
|
||||
);
|
||||
} else {
|
||||
// Unknown questions: priority + 1 to ensure non-zero weight
|
||||
return (q.priorityPoints + 1).toDouble();
|
||||
}
|
||||
}).toList();
|
||||
|
||||
// Calculate cumulative weights
|
||||
final cumulativeWeights = <double>[];
|
||||
double sum = 0.0;
|
||||
for (final weight in weights) {
|
||||
sum += weight;
|
||||
cumulativeWeights.add(sum);
|
||||
}
|
||||
|
||||
// Select random value in range [0, sum)
|
||||
final randomValue = _random.nextDouble() * sum;
|
||||
|
||||
// Find the index corresponding to the random value
|
||||
for (int i = 0; i < cumulativeWeights.length; i++) {
|
||||
if (randomValue < cumulativeWeights[i]) {
|
||||
return candidates[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to last item (shouldn't happen, but safety)
|
||||
return candidates.last;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user