Adaptive learning curriculum routing algorithm combining FSRS spaced repetition with progress interference decay
npm install adaptive-learning-coreA comprehensive spaced repetition library with three approaches:
1. Time-Based FSRS - Traditional spaced repetition with time decay
2. Count-Based - Problem-count intervals for intensive practice
3. Hybrid - Combines both for optimal results
- Time-Based FSRS: Exponential decay with progress interference
- Count-Based Scheduling: Discrete intervals [2, 5, 10, 20, 40] problems
- Hybrid Scheduler: Best of both worlds - count for active practice, time for breaks
- Adaptive Routing: Prerequisite checking and domain balancing
- A/B Testing: Built-in cohort assignment for experimentation
- Zero Dependencies: Pure JavaScript with no external runtime dependencies
- Fully Tested: 121 comprehensive tests with Jest
``bash`
npm install @adaptive-learning/core
`javascript
import { Router, DecayCalculator, configure } from '@adaptive-learning/core';
// Optional: Configure algorithm parameters
configure({
interferenceRate: 0.020, // 2% decay per interfering skill
muscleMemoryFloor: 40.0, // Minimum retention percentage
prerequisiteThreshold: 60.0, // 60% mastery needed to advance
weakDomainThreshold: 50.0, // Trigger balancing below 50%
domainCheckInterval: 5 // Check domains every 5 skills
});
// Your data models
const user = { id: 1, learningCohort: null }; // Cohort assigned automatically
const chapters = [
{ id: 'ch-1', title: 'Docker Basics', domain: 'containers', prerequisiteSkillIds: [] },
{ id: 'ch-2', title: 'Volumes', domain: 'storage', prerequisiteSkillIds: ['ch-1'] }
];
const masteries = [
{
canonicalCommand: 'ch-1',
proficiencyScore: 75,
stability: 7.0,
lastUsedAt: new Date(),
chaptersAtMastery: 0
}
];
// Determine next chapter to learn
const router = new Router({
user,
currentSkill: chapters[0],
allSkills: chapters,
masteries
});
const result = router.nextSkill();
console.log(result);
// {
// nextSkill: { id: 'ch-2', ... },
// reason: 'linear', // or 'prerequisite_gap', 'weak_domain'
// message: null, // User-facing message if detour
// detour: false // True if prerequisite detour
// }
`
Skills decay over time and with new learning:
Formula: Current Score = Base × FSRS Retention × Interference Factor
#### FSRS (Time-based Decay)
``
R(t) = e^(-t/S)
- t: Days since last practice
- S: Stability in days (increases with successful reviews)
- R: Retention factor (0-1)
#### Progress Interference
``
Interference = Skills Learned Since × Rate × Similarity
Factor = 1 - min(Interference, 0.60)
- Recent 3 skills are protected (no interference)
- Maximum 60% interference cap
- Default 15% similarity between skills
The router makes intelligent decisions based on:
Priority Order:
1. Prerequisite Check (highest priority)
- Detours if prerequisite < 60% mastery
2. Domain Balancing (every N skills)
- Routes to weakest skill in weakest domain if < 50% avg
3. Linear Progression (default)
- Continue with next skill in sequence
- Linear Cohort: Traditional sequential learning (control group)
- Adaptive Cohort: Smart routing with prerequisites + domain balancing (experimental)
`javascript
import { configure, getConfiguration, resetConfiguration } from '@adaptive-learning/core';
// Modify shared configuration
configure({
interferenceRate: 0.025,
defaultStability: 10.0
});
// Get current configuration
const config = getConfiguration();
// Reset to defaults
resetConfiguration();
`
Available Parameters:
| Parameter | Default | Range | Description |
|-----------|---------|-------|-------------|
| interferenceRate | 0.020 | 0.010-0.030 | Decay per interfering skill (2%) |muscleMemoryFloor
| | 40.0 | 30-50 | Minimum retention % |protectedRecentCount
| | 3 | - | Recent skills protected from interference |defaultStability
| | 7.0 | 3-14 | Default FSRS stability (days) |prerequisiteThreshold
| | 60.0 | 50-70 | % mastery needed to advance |weakDomainThreshold
| | 50.0 | 40-60 | Triggers domain balancing |domainCheckInterval
| | 5 | 3-10 | Check domains every N skills |
`javascript
import { DecayCalculator } from '@adaptive-learning/core';
const calculator = new DecayCalculator();
// Calculate current decayed score
const currentScore = calculator.calculateCurrentScore({
baseScore: 80,
stability: 7.0,
lastUsedAt: new Date('2024-01-01'),
skillsLearnedSince: 10,
similarities: { 'skill-2': 0.75, 'skill-3': 0.60 }
});
// Returns: ~45-55 (decayed due to time + interference)
// Calculate FSRS retention only
const retention = calculator.calculateFSRSRetention(
new Date('2024-01-01'), // lastUsedAt
7.0 // stability
);
// Returns: ~0.368 after 7 days (e^(-7/7))
// Calculate interference only
const interference = calculator.calculateInterference(
10, // skillsLearnedSince
{ 'skill-2': 0.75, 'skill-3': 0.60 } // similarities
);
// Returns: 0.40-1.0 (interference factor)
// Update stability after review
const newStability = calculator.updateStability(
7.0, // currentStability
true, // reviewSuccess
1 // difficulty (1=very easy, 5=very hard)
);
// Returns: 17.5 (2.5x increase for very easy)
`
`javascript
import { Router } from '@adaptive-learning/core';
const router = new Router({
user, // { learningCohort: 'linear' | 'adaptive' }
currentSkill, // Currently completed skill
allSkills, // Array of all skills/chapters
masteries, // Array of mastery records
config // Optional custom configuration
});
const result = router.nextSkill();
// {
// nextSkill:
// reason: 'linear' | 'prerequisite_gap' | 'weak_domain',
// message: 'Let\'s review X first...' | null,
// detour: true | false
// }
`
Model Requirements:
User:
`javascript`
{
learningCohort: 'linear' | 'adaptive' | null // Auto-assigned if null
}
Skill/Chapter:
`javascript`
{
id: string, // or slug
title: string,
domain: string, // or category (optional)
prerequisiteSkillIds: string[] // or prerequisites (optional)
}
Mastery:
`javascript`
{
canonicalCommand: string, // or skillId - links to skill.id
proficiencyScore: number, // 0-100
stability: number, // FSRS stability in days
lastUsedAt: Date, // When last practiced
chaptersAtMastery: number // Position when mastered (for interference)
}
`javascript
import { CohortAssigner } from '@adaptive-learning/core';
// Assign random cohort (50/50 split)
const cohort = CohortAssigner.assign();
// Returns: 'linear' or 'adaptive'
// Validate cohort
CohortAssigner.valid('linear'); // true
CohortAssigner.valid('invalid'); // false
// Get all cohorts
CohortAssigner.all();
// Returns: ['linear', 'adaptive']
`
`javascript
import { Router, DecayCalculator } from '@adaptive-learning/core';
// 1. User completes a chapter
const completedChapter = chapters.find(c => c.id === 'ch-1');
const userScore = 85; // User's score on the chapter
const difficulty = 2; // User-rated difficulty (1-5)
// 2. Find or create mastery record
let mastery = masteries.find(m => m.canonicalCommand === completedChapter.id);
if (!mastery) {
mastery = {
canonicalCommand: completedChapter.id,
proficiencyScore: 0,
stability: 7.0,
lastUsedAt: new Date(),
chaptersAtMastery: masteries.length
};
masteries.push(mastery);
}
// 3. Update stability based on performance
const calculator = new DecayCalculator();
const success = userScore >= 70;
mastery.stability = calculator.updateStability(
mastery.stability,
success,
difficulty
);
// 4. Update mastery score and timestamp
mastery.proficiencyScore = userScore;
mastery.lastUsedAt = new Date();
// 5. Route to next chapter
const router = new Router({
user,
currentSkill: completedChapter,
allSkills: chapters,
masteries
});
const result = router.nextSkill();
// 6. Show message if detour
if (result.detour && result.message) {
showFlashMessage(result.message);
// "Let's review Docker Networks first - it's a prerequisite for what's next."
}
// 7. Navigate to next chapter
if (result.nextSkill) {
navigateTo(/chapters/${result.nextSkill.id});`
} else {
showCompletionCelebration(); // End of course!
}
`javascript
import { DecayCalculator } from '@adaptive-learning/core';
const calculator = new DecayCalculator();
// Get all skills that need review (decayed below 70%)
const skillsNeedingReview = masteries
.map(mastery => {
const skill = chapters.find(c => c.id === mastery.canonicalCommand);
const skillsLearnedSince = masteries.length - mastery.chaptersAtMastery - 1;
const currentScore = calculator.calculateCurrentScore({
baseScore: mastery.proficiencyScore,
stability: mastery.stability,
lastUsedAt: mastery.lastUsedAt,
skillsLearnedSince,
similarities: {} // Can add similarity data if available
});
return {
skill,
originalScore: mastery.proficiencyScore,
currentScore,
decayAmount: mastery.proficiencyScore - currentScore
};
})
.filter(item => item.currentScore < 70)
.sort((a, b) => a.currentScore - b.currentScore); // Weakest first
console.log('Skills needing review:', skillsNeedingReview);
`
`javascript
import { Configuration, Router } from '@adaptive-learning/core';
// Docker DCA course (fast-paced, practical skills)
const dockerConfig = Configuration.create({
interferenceRate: 0.020,
muscleMemoryFloor: 40.0,
defaultStability: 7.0,
prerequisiteThreshold: 60.0
});
// Academic course (slower, deeper mastery)
const academicConfig = Configuration.create({
interferenceRate: 0.010,
muscleMemoryFloor: 50.0,
defaultStability: 14.0,
prerequisiteThreshold: 75.0
});
// Use course-specific config
const router = new Router({
user,
currentSkill,
allSkills,
masteries,
config: dockerConfig // Pass custom config
});
`
`bashRun all tests
npm test
All tests pass:
- ✅ 69 comprehensive tests
- ✅ DecayCalculator: FSRS formulas, interference, stability updates
- ✅ Router: Linear routing, prerequisite checking, domain balancing
- ✅ Configuration: Defaults, customization, singleton
- ✅ CohortAssigner: Random assignment, validation
Mathematical Formulas
$3
`
R(t) = e^(-t/S)Where:
- R = retention factor (0-1)
- t = time elapsed (days)
- S = stability (days)
- e = Euler's number (≈2.71828)
`Examples:
- After 1 day with 7-day stability: e^(-1/7) ≈ 0.867 (86.7%)
- After 7 days with 7-day stability: e^(-7/7) ≈ 0.368 (36.8%)
- After 14 days with 7-day stability: e^(-14/7) ≈ 0.135 (13.5%)
$3
`
I = Σ (rate × similarity × count)
= (skills_learned - protected_count) × interference_rate × avg_similarityInterference_Factor = 1 - min(I, 0.60)
Where:
- rate = 0.020 (2% default)
- protected_count = 3 (default)
- avg_similarity = 0.15 (default if not specified)
- max interference = 60%
`$3
`
Current_Score = Base × R(t) × Interference_Factor
= Base × e^(-t/S) × (1 - I)
>= muscle_memory_floor (40%)
`$3
`
Success:
S_new = S_old × factor
factor = 2.5 - (difficulty - 1) × 0.325
- difficulty 1 (very easy): 2.5x
- difficulty 2 (easy): 2.175x
- difficulty 3 (medium): 1.85x
- difficulty 4 (hard): 1.525x
- difficulty 5 (very hard): 1.2x
S_new = min(S_new, 180 days)Failure:
S_new = S_old × 0.5
S_new = max(S_new, 1 day)
`Architecture
`
adaptive-learning-js/
├── src/
│ ├── Configuration.js # Algorithm parameters
│ ├── DecayCalculator.js # FSRS + Interference calculations
│ ├── Router.js # Adaptive routing logic
│ ├── CohortAssigner.js # A/B testing
│ └── index.js # Main exports
├── test/
│ ├── Configuration.test.js
│ ├── DecayCalculator.test.js
│ ├── Router.test.js
│ └── CohortAssigner.test.js
├── examples/
│ └── integration.js # Complete integration example
└── package.json
`Design Principles
1. Zero Dependencies: No runtime dependencies, only dev dependencies for testing
2. Flexible Models: Adapter methods support different attribute names (id/slug, domain/category, etc.)
3. Predictable: Pure functions with no hidden state
4. Well-Tested: Comprehensive test coverage with edge cases
5. Configurable: All algorithm parameters can be tuned per use case
Comparison with Ruby Gem
This JavaScript library is a functionally identical port of the Ruby gem
adaptive-learning-gem:| Feature | Ruby Gem | JS Library |
|---------|----------|------------|
| FSRS Decay | ✅ | ✅ |
| Progress Interference | ✅ | ✅ |
| Adaptive Routing | ✅ | ✅ |
| Prerequisite Checking | ✅ | ✅ |
| Domain Balancing | ✅ | ✅ |
| A/B Testing | ✅ | ✅ |
| Zero Dependencies | ✅ | ✅ |
| Test Coverage | ✅ 40+ tests | ✅ 69 tests |
Use Cases
- E-learning Platforms: Adaptive course sequencing
- Coding Bootcamps: Personalized curriculum paths
- Certification Training: Prerequisite-aware learning (Docker DCA, AWS, etc.)
- Language Learning: Spaced repetition with contextual interference
- Corporate Training: Domain-balanced skill development
- MOOCs: A/B testing different pedagogical approaches
Hybrid Scheduler (NEW!)
For coding interview prep, daily practice, or variable-frequency learning.
$3
Time-Based Only:
- Active users (10+ problems/day) don't benefit from time decay
- Reviews trigger too slowly for intensive practice
- Doesn't leverage interleaving benefits
Count-Based Only:
- Users who take breaks don't review (forgotten but count not met)
- Casual users wait forever for count thresholds
- Ignores real forgetting over time
$3
Triggers reviews when EITHER condition is met:
1. Count-based (primary): After solving N other problems
2. Time-based (fallback): When retention drops below 70%
$3
`javascript
import { HybridScheduler } from '@adaptive-learning/core';const scheduler = new HybridScheduler({
retentionThreshold: 0.70, // Review when retention < 70%
timeWeight: 0.5, // Balance both priorities
countWeight: 0.5
});
// Create problem history
const history = scheduler.createProblemHistory('two-pointers-1', 7.0);
// User solves a problem
scheduler.completeProblem(problemHistories, 'two-pointers-1', true, 2);
// Check if review needed
const decision = scheduler.shouldReview({
problemsSolvedSince: 10, // Solved 10 other problems
consecutiveCorrect: 1, // Need 5 for count trigger
lastPracticedAt: threeDaysAgo, // 3 days ago
stability: 7.0
});
console.log(decision.shouldReview); // true
console.log(decision.reason); // 'count' (primary trigger)
`$3
Count Trigger (Primary):
- Active daily users solving many problems
- Provides optimal interleaving
- Intervals: [2, 5, 10, 20, 40] problems
Time Trigger (Fallback):
- Users who take breaks (vacation, busy week)
- Casual users with low problem volume
- Prevents "review starvation"
$3
#### Scenario 1: Active Daily User
`javascript
// User solves 15 problems/day
// Problem A last seen 1 day ago, 10 problems solved sincescheduler.shouldReview({
problemsSolvedSince: 10, // OVER interval (need 5)
consecutiveCorrect: 1,
lastPracticedAt: oneDayAgo, // Only 1 day (high retention)
stability: 7.0
});
// → shouldReview: true
// → reason: 'count' ← Count triggered, time didn't
`#### Scenario 2: User with Break
`javascript
// User on vacation for 2 weeks, no problems solvedscheduler.shouldReview({
problemsSolvedSince: 0, // No problems (count won't trigger)
consecutiveCorrect: 2, // Need 10 problems
lastPracticedAt: fourteenDaysAgo, // 2 weeks!
stability: 7.0
});
// → shouldReview: true
// → reason: 'time' ← Time caught the gap!
`#### Scenario 3: Casual User
`javascript
// User solves 2 problems/week (would take 5 weeks to reach count threshold)scheduler.shouldReview({
problemsSolvedSince: 2, // Only 2 problems (need 10)
consecutiveCorrect: 2,
lastPracticedAt: sevenDaysAgo, // 1 week ago
stability: 7.0
});
// → shouldReview: true
// → reason: 'time' ← Time prevents starvation
`$3
`javascript
// Create scheduler
const scheduler = new HybridScheduler({
retentionThreshold: 0.70, // Review when < 70% retention
timeWeight: 0.5, // Weight for time priority
countWeight: 0.5 // Weight for count priority
});// Check if review needed
const decision = scheduler.shouldReview({
problemsSolvedSince,
consecutiveCorrect,
lastPracticedAt,
stability
});
// Returns: { shouldReview, reason, countPriority, timePriority, retention, ... }
// Select most urgent problem
const next = scheduler.selectNextProblem(problems);
// Returns: { problem, decision, priority }
// Complete a problem (updates ALL state)
scheduler.completeProblem(problemHistories, problemId, wasCorrect, difficulty);
// Get statistics
const stats = scheduler.getReviewStatistics(problems);
// Returns: { total, countTriggered, timeTriggered, averagePriority, ... }
`$3
See
examples/hybrid-interview-prep.js for complete working example with:
- Active daily user scenario
- User with breaks scenario
- Casual user scenario
- Statistics comparison$3
| Approach | Use Case | Example Platform |
|----------|----------|------------------|
| Time-Based FSRS | Self-paced courses, real-world skills | Docker DCA, Language learning |
| Count-Based | Intensive daily practice | LeetCode daily grind |
| Hybrid | Mixed practice patterns | Interview prep courses |
$3
Can also use count-based scheduling independently:
`javascript
import { CountBasedScheduler } from '@adaptive-learning/core';const scheduler = new CountBasedScheduler();
// Get interval for mastery level
const interval = scheduler.getReviewInterval(consecutiveCorrect);
// 0 → 2, 1 → 5, 2 → 10, 3 → 20, 4+ → 40
// Check if due
const due = scheduler.shouldReview(problemsSolvedSince, consecutiveCorrect);
// Calculate overdueness
const priority = scheduler.calculatePriority(problemsSolvedSince, consecutiveCorrect);
// Complete problem (auto-updates all counters)
scheduler.completeProblem(problemHistories, problemId, wasCorrect);
``MIT
Contributions welcome! Please open an issue or PR.
Based on research in:
- FSRS (Free Spaced Repetition Scheduler)
- Spaced repetition algorithms
- Count-based interleaving for problem-solving
- Adaptive learning systems
- Curriculum sequencing theory