Feature detail
Scheduler
Concept-level mastery math, spacing, and next-concept selection stay policy-driven so each game can own its own UI and gating rules.
Worked example
Scheduler turn update
The scheduler spaces a concept after a clean pass, then picks the next eligible concept.
Input
{
"policy": {
"masteryTarget": 3,
"independentGaps": [
2,
5,
8
],
"supportedGap": 1,
"failureGap": 1,
"subskillIds": [
"recognition",
"setup"
]
},
"before": {
"factoring": {
"conceptId": "factoring",
"independentPassCount": 0,
"supportedPassCount": 0,
"nextEligibleTurn": 1,
"lastSeenTurn": null,
"attempts": 0,
"supplementalExposureCount": 0,
"assistedCount": 0,
"skippedCount": 0,
"recentStruggleCount": 0,
"recoveryDue": false,
"retentionCheckEligibleTurn": null,
"retentionCheckPassed": false,
"mastered": false,
"lastOutcome": null,
"subskillStats": {
"recognition": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
},
"setup": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
}
}
},
"graphing": {
"conceptId": "graphing",
"independentPassCount": 0,
"supportedPassCount": 0,
"nextEligibleTurn": 1,
"lastSeenTurn": null,
"attempts": 0,
"supplementalExposureCount": 0,
"assistedCount": 0,
"skippedCount": 0,
"recentStruggleCount": 0,
"recoveryDue": false,
"retentionCheckEligibleTurn": null,
"retentionCheckPassed": false,
"mastered": false,
"lastOutcome": null,
"subskillStats": {
"recognition": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
},
"setup": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
}
}
}
},
"action": {
"conceptId": "factoring",
"outcome": "independent_correct",
"currentTurn": 1
}
}Output
{
"afterPass": {
"factoring": {
"conceptId": "factoring",
"independentPassCount": 1,
"supportedPassCount": 0,
"nextEligibleTurn": 4,
"lastSeenTurn": 1,
"attempts": 1,
"supplementalExposureCount": 0,
"assistedCount": 0,
"skippedCount": 0,
"recentStruggleCount": 0,
"recoveryDue": false,
"retentionCheckEligibleTurn": null,
"retentionCheckPassed": false,
"mastered": false,
"lastOutcome": "independent_correct",
"subskillStats": {
"recognition": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
},
"setup": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
}
}
},
"graphing": {
"conceptId": "graphing",
"independentPassCount": 0,
"supportedPassCount": 0,
"nextEligibleTurn": 1,
"lastSeenTurn": null,
"attempts": 0,
"supplementalExposureCount": 0,
"assistedCount": 0,
"skippedCount": 0,
"recentStruggleCount": 0,
"recoveryDue": false,
"retentionCheckEligibleTurn": null,
"retentionCheckPassed": false,
"mastered": false,
"lastOutcome": null,
"subskillStats": {
"recognition": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
},
"setup": {
"attempts": 0,
"cleanPasses": 0,
"supportedPasses": 0,
"misses": 0,
"lastMissedTurn": null
}
}
}
},
"nextConceptAtTurn2": "graphing"
}Real source excerpt
Scheduler state and outcome transitions
export function buildInitialConceptSchedule<TSubskill extends string = string>(
conceptIds: readonly string[],
policy?: SchedulerPolicy<TSubskill>
): ConceptScheduleMap<TSubskill> {
const resolvedPolicy = resolvePolicy(policy)
const schedule = {} as ConceptScheduleMap<TSubskill>
for (const conceptId of conceptIds) {
schedule[conceptId] = {
conceptId,
independentPassCount: 0,
supportedPassCount: 0,
nextEligibleTurn: 1,
lastSeenTurn: null,
attempts: 0,
supplementalExposureCount: 0,
assistedCount: 0,
skippedCount: 0,
recentStruggleCount: 0,
recoveryDue: false,
retentionCheckEligibleTurn: null,
retentionCheckPassed: false,
mastered: false,
lastOutcome: null,
subskillStats: buildEmptySubskillStats(resolvedPolicy),
}
}
return schedule
}
export function mergeConceptSchedule<TSubskill extends string = string>(
conceptIds: readonly string[],
stored: unknown,
policy?: SchedulerPolicy<TSubskill>
): ConceptScheduleMap<TSubskill> {
const resolvedPolicy = resolvePolicy(policy)
const merged = buildInitialConceptSchedule(conceptIds, resolvedPolicy)
if (!isRecord(stored)) {
return merged
}
for (const conceptId of conceptIds) {
const value = stored[conceptId]
if (!isRecord(value)) continue
const independentPassCount = readNonNegativeInteger(value.independentPassCount)
const supportedPassCount = readNonNegativeInteger(value.supportedPassCount)
const nextEligibleTurn = readNonNegativeInteger(value.nextEligibleTurn, 1)
const mastered = independentPassCount >= resolvedPolicy.masteryTarget
const storedRetentionCheckEligibleTurn = readNullableInteger(value.retentionCheckEligibleTurn)
merged[conceptId] = {
conceptId,
independentPassCount,
supportedPassCount,
nextEligibleTurn,
lastSeenTurn: readNullableInteger(value.lastSeenTurn),
attempts: readNonNegativeInteger(value.attempts),
supplementalExposureCount: readNonNegativeInteger(value.supplementalExposureCount),
assistedCount: readNonNegativeInteger(value.assistedCount),
skippedCount: readNonNegativeInteger(value.skippedCount),
recentStruggleCount: readNonNegativeInteger(value.recentStruggleCount),
recoveryDue: typeof value.recoveryDue === 'boolean' ? value.recoveryDue : false,
retentionCheckEligibleTurn: mastered
? (storedRetentionCheckEligibleTurn ?? nextEligibleTurn)
: null,
retentionCheckPassed: mastered
? (typeof value.retentionCheckPassed === 'boolean' ? value.retentionCheckPassed : false)
: false,
mastered,
lastOutcome: isPracticeOutcome(value.lastOutcome) ? value.lastOutcome : null,
subskillStats: mergeStoredSubskillStats(value.subskillStats, resolvedPolicy),
}
}
return merged
}
export function isConceptMastered<TSubskill extends string = string>(
concept: ConceptScheduleState<TSubskill> | undefined,
policy?: SchedulerPolicy<TSubskill>
): boolean {
const resolvedPolicy = resolvePolicy(policy)
return concept !== undefined && concept.independentPassCount >= resolvedPolicy.masteryTarget
}
export function isRetentionDue<TSubskill extends string = string>(
concept: ConceptScheduleState<TSubskill> | undefined,
currentTurn: number,
policy?: SchedulerPolicy<TSubskill>
): boolean {
return Boolean(
concept
&& isConceptMastered(concept, policy)
&& !concept.retentionCheckPassed
&& concept.retentionCheckEligibleTurn !== null
&& concept.retentionCheckEligibleTurn <= currentTurn
)
}
export function applyConceptOutcome<TSubskill extends string = string>(
progressMap: ConceptScheduleMap<TSubskill>,
conceptId: string,
outcome: PracticeOutcome,
currentTurn: number,
options: {
policy?: SchedulerPolicy<TSubskill>
subskillUpdates?: readonly SubskillUpdate<TSubskill>[]
} = {}
): ConceptScheduleMap<TSubskill> {
const current = progressMap[conceptId]
if (!current) return progressMap
const resolvedPolicy = resolvePolicy(options.policy)
const wasMastered = isConceptMastered(current, resolvedPolicy)
const subskillStats = applySubskillUpdates(
current.subskillStats,
options.subskillUpdates ?? []
)
let independentPassCount = current.independentPassCount
let supportedPassCount = current.supportedPassCount
let nextEligibleTurn = current.nextEligibleTurn
let assistedCount = current.assistedCount
let skippedCount = current.skippedCount
let recentStruggleCount = current.recentStruggleCount
let recoveryDue = current.recoveryDue
let retentionCheckEligibleTurn = wasMastered
? (current.retentionCheckEligibleTurn ?? current.nextEligibleTurn)
: current.retentionCheckEligibleTurn
let retentionCheckPassed = wasMastered ? current.retentionCheckPassed : false
if (outcome === 'independent_correct') {
independentPassCount += 1
recentStruggleCount = 0
recoveryDue = false
nextEligibleTurn = nextEligibleTurnForGap(
currentTurn,
getIndependentGap(resolvedPolicy, independentPassCount)
)
if (!wasMastered && independentPassCount >= resolvedPolicy.masteryTarget) {
retentionCheckPassed = false
retentionCheckEligibleTurn = nextEligibleTurn
} else if (wasMastered && retentionCheckEligibleTurn !== null && currentTurn >= retentionCheckEligibleTurn) {
retentionCheckPassed = true
}
}
if (outcome === 'supported_correct') {
supportedPassCount += 1
recentStruggleCount += 1
recoveryDue = true
nextEligibleTurn = Math.min(
current.nextEligibleTurn,
nextEligibleTurnForGap(currentTurn, resolvedPolicy.supportedGap)
)
}
if (outcome === 'assisted') {
assistedCount += 1
recentStruggleCount += 1
recoveryDue = true
nextEligibleTurn = Math.min(
current.nextEligibleTurn,
nextEligibleTurnForGap(currentTurn, resolvedPolicy.failureGap)
)
}
if (outcome === 'skipped') {
skippedCount += 1
recentStruggleCount += 1
recoveryDue = true
nextEligibleTurn = Math.min(
current.nextEligibleTurn,
nextEligibleTurnForGap(currentTurn, resolvedPolicy.failureGap)
)
}
const mastered = independentPassCount >= resolvedPolicy.masteryTarget
return {
...progressMap,
[conceptId]: {
...current,
independentPassCount,
supportedPassCount,
nextEligibleTurn,
lastSeenTurn: currentTurn,
attempts: current.attempts + 1,
assistedCount,
skippedCount,
recentStruggleCount,
recoveryDue,
retentionCheckEligibleTurn: mastered ? retentionCheckEligibleTurn : null,
retentionCheckPassed: mastered ? retentionCheckPassed : false,
mastered,
lastOutcome: outcome,
subskillStats,
},
}
}