merge with practice_engine

This commit is contained in:
gitea
2026-01-02 20:18:48 +01:00
parent e3ffc4d556
commit cad489b678
37 changed files with 3172 additions and 1 deletions
@@ -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;
}
}