RGreview-game-corethe engine behind concept-first review games

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.

schedulerconcept

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

src/scheduler/base.ts
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,
    },
  }
}