All files / web/src/lib/curriculum comfort-level.ts

100% Statements 174/174
94.73% Branches 18/19
100% Functions 3/3
100% Lines 174/174

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 1752x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 36x 36x 36x 36x 144x 144x 140x 140x 140x 140x 144x 36x 36x 32x 32x 36x 56x 56x 56x 32x 56x 24x 24x 24x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 56x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 6x 6x 6x 6x 6x 6x 6x 6x 18x 18x 4x 4x 18x 14x 14x 14x 18x 6x 6x 6x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 2x 19x 19x 19x 19x 4x 4x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x  
/**
 * Comfort Level Computation
 *
 * Computes a 0-1 "comfort level" from existing BKT mastery data and session mode.
 * This drives dynamic term count scaling - higher comfort → more terms per problem.
 *
 * Algorithm:
 * 1. Base: Confidence-weighted average of pKnown across practicing skills
 * 2. Mode multiplier: remediation × 0.6, progression × 0.85, maintenance × 1.0
 * 3. Skill count bonus: min(0.15, log(skillCount + 1) / 20)
 * 4. Clamp to [0, 1]; no BKT data → conservative default 0.3
 */
 
import type { BktModeResult, SkillBktResult } from './bkt'
import type { SessionMode } from './session-mode'
import type { TermCountExplanation } from './config/term-count-scaling'
 
// =============================================================================
// Types
// =============================================================================
 
export interface ComfortLevelResult {
  comfortLevel: number
  factors: TermCountExplanation['factors']
}
 
// =============================================================================
// Mode Multipliers
// =============================================================================
 
const MODE_MULTIPLIERS: Record<string, number> = {
  remediation: 0.6,
  progression: 0.85,
  maintenance: 1.0,
}
 
/** Conservative default when no BKT data is available */
const NO_DATA_DEFAULT = 0.3
 
// =============================================================================
// Computation
// =============================================================================
 
/**
 * Compute the comfort level for a student based on their mastery data and session mode.
 *
 * @param bktResults - BKT results keyed by skillId (undefined = no BKT data available)
 * @param practicingSkillIds - Skills currently in the student's practice rotation
 * @param sessionMode - Current session mode (remediation/progression/maintenance)
 * @returns ComfortLevelResult with the comfort value and individual factors
 */
export function computeComfortLevel(
  bktResults: Map<string, SkillBktResult> | undefined,
  practicingSkillIds: string[],
  sessionMode: SessionMode
): ComfortLevelResult {
  const modeType = sessionMode.type
  const modeMultiplier = MODE_MULTIPLIERS[modeType] ?? 1.0
 
  // Skill count bonus: breadth of experience
  const skillCountBonus = Math.min(0.15, Math.log(practicingSkillIds.length + 1) / 20)
 
  // Base: confidence-weighted average of pKnown
  let avgMastery: number | null = null
 
  if (bktResults && bktResults.size > 0) {
    let totalWeight = 0
    let weightedSum = 0
 
    for (const skillId of practicingSkillIds) {
      const bkt = bktResults.get(skillId)
      if (bkt) {
        const weight = bkt.confidence
        weightedSum += bkt.pKnown * weight
        totalWeight += weight
      }
    }
 
    if (totalWeight > 0) {
      avgMastery = weightedSum / totalWeight
    }
  }
 
  let comfortLevel: number
  if (avgMastery !== null) {
    comfortLevel = avgMastery * modeMultiplier + skillCountBonus
  } else {
    // No BKT data → conservative default
    comfortLevel = NO_DATA_DEFAULT
  }
 
  // Clamp to [0, 1]
  comfortLevel = Math.max(0, Math.min(1, comfortLevel))
 
  return {
    comfortLevel,
    factors: {
      avgMastery,
      sessionMode: modeType,
      modeMultiplier,
      skillCountBonus,
    },
  }
}
 
// =============================================================================
// Per-Mode Comfort
// =============================================================================
 
/**
 * Compute comfort levels separately for each mode (abacus, visualization, linear).
 *
 * For each mode present in byModeBkt, builds a SkillBktResult map and delegates
 * to the existing computeComfortLevel(). Modes with no BKT data get the
 * conservative 0.3 default (appropriate for unpracticed modes).
 *
 * @param byModeBkt - Per-mode BKT results from computeBktFromHistory
 * @param practicingSkillIds - Skills in the student's practice rotation
 * @param sessionMode - Current session mode
 * @returns Record mapping mode names to their ComfortLevelResult
 */
export function computeComfortLevelByMode(
  byModeBkt: Partial<Record<string, BktModeResult>> | undefined,
  practicingSkillIds: string[],
  sessionMode: SessionMode
): Record<string, ComfortLevelResult> {
  const modes = ['abacus', 'visualization', 'linear'] as const
  const result: Record<string, ComfortLevelResult> = {}
 
  for (const mode of modes) {
    const modeData = byModeBkt?.[mode]
    if (modeData && modeData.skills.length > 0) {
      const bktMap = new Map(modeData.skills.map((s) => [s.skillId, s]))
      result[mode] = computeComfortLevel(bktMap, practicingSkillIds, sessionMode)
    } else {
      // No data for this mode → conservative default
      result[mode] = computeComfortLevel(undefined, practicingSkillIds, sessionMode)
    }
  }
 
  return result
}
 
// =============================================================================
// Override Helper
// =============================================================================
 
/**
 * Apply a parent/teacher term count override as a ceiling.
 *
 * The override doesn't raise the computed range — it only caps it.
 * If no override is provided, the computed range is returned as-is.
 *
 * @param computed - The dynamically computed term count range
 * @param override - Parent/teacher cap (null/undefined = no cap)
 * @returns The final range, capped by the override if applicable
 */
export function applyTermCountOverride(
  computed: { min: number; max: number },
  override: { min: number; max: number } | null | undefined
): { min: number; max: number } {
  if (!override) {
    return computed
  }
 
  // Override acts as ceiling — caps the computed range but doesn't raise it
  const finalMax = Math.min(computed.max, override.max)
  const finalMin = Math.min(computed.min, override.max) // min can't exceed override.max either
 
  return {
    min: Math.max(2, Math.min(finalMin, finalMax)),
    max: Math.max(2, finalMax),
  }
}