diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 4c6408e6..c4005ce8 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -251,7 +251,10 @@ "Bash(\"apps/web/src/lib/curriculum/placement-test.ts\" )", "Bash(\"apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts\")", "Bash(mcp__sqlite__read_query:*)", - "Bash(mcp__sqlite__describe_table:*)" + "Bash(mcp__sqlite__describe_table:*)", + "Bash(git diff:*)", + "Bash(git show:*)", + "Bash(npx tsx:*)" ], "deny": [], "ask": [] diff --git a/apps/web/.claude/BKT_DESIGN_SPEC.md b/apps/web/.claude/BKT_DESIGN_SPEC.md index c856a3f5..6f178758 100644 --- a/apps/web/.claude/BKT_DESIGN_SPEC.md +++ b/apps/web/.claude/BKT_DESIGN_SPEC.md @@ -68,7 +68,7 @@ export interface SlotResult { timestamp: number; responseTimeMs: number; userAnswer: number | null; - helpLevel: 0 | 1 | 2 | 3; + helpLevel: 0 | 1; // Boolean: 0 = no help, 1 = used help } ``` @@ -220,20 +220,16 @@ export function updateOnIncorrect( // src/lib/curriculum/bkt/evidence-quality.ts /** - * Adjust observation weight based on help level. - * More help = less confident the student really knows it. + * Adjust observation weight based on whether help was used. + * Using help = less confident the student really knows it. + * + * Note: Help is binary (0 = no help, 1 = used help). + * We can't determine which skill needed help for multi-skill problems, + * so we apply the discount uniformly and let conjunctive BKT identify + * weak skills from aggregated evidence. */ -export function helpLevelWeight(helpLevel: 0 | 1 | 2 | 3): number { - switch (helpLevel) { - case 0: - return 1.0; // No help - full evidence - case 1: - return 0.8; // Minor hint - slight reduction - case 2: - return 0.5; // Significant help - halve evidence - case 3: - return 0.5; // Full help - halve evidence - } +export function helpLevelWeight(helpLevel: 0 | 1): number { + return helpLevel === 0 ? 1.0 : 0.5; // 50% weight for helped answers } /** diff --git a/apps/web/scripts/seedTestStudents.ts b/apps/web/scripts/seedTestStudents.ts index 64581a50..72a130d0 100644 --- a/apps/web/scripts/seedTestStudents.ts +++ b/apps/web/scripts/seedTestStudents.ts @@ -57,8 +57,56 @@ * โš–๏ธ Multi-Weak Remediation - Many weak skills needing remediation * ๐Ÿ•ฐ๏ธ Stale Skills Test - Skills at various staleness levels * ๐Ÿ’ฅ NaN Stress Test - Stress tests BKT NaN handling + * ๐ŸงŠ Forgotten Weaknesses - Weak skills that are also stale */ +import { parseArgs } from 'node:util' + +// ============================================================================= +// CLI Argument Parsing +// ============================================================================= + +const { values: cliArgs } = parseArgs({ + options: { + help: { type: 'boolean', short: 'h', default: false }, + list: { type: 'boolean', short: 'l', default: false }, + name: { type: 'string', short: 'n', multiple: true, default: [] }, + category: { type: 'string', short: 'c', multiple: true, default: [] }, + 'dry-run': { type: 'boolean', default: false }, + }, + strict: true, + allowPositionals: false, +}) + +function showHelp(): void { + console.log(` +Usage: + npm run seed:test-students [options] + +Options: + --help, -h Show this help message + --list, -l List all available students and categories + --name, -n Seed specific student(s) by name (can use multiple times) + --category, -c Seed all students in a category (can use multiple times) + --dry-run Show what would be seeded without creating students + +Categories: + bkt Core BKT scenarios (deficient, blocker, progressing, etc.) + session Session mode tests (remediation, progression, maintenance) + edge Edge cases (empty, single skill, high volume, NaN stress test) + +Examples: + npm run seed:test-students # Seed all students + npm run seed:test-students -- --list # List available options + npm run seed:test-students -- -n "๐Ÿ’ฅ NaN Stress Test" + npm run seed:test-students -- -c edge # Seed all edge case students + npm run seed:test-students -- -c bkt -c session + npm run seed:test-students -- -n "๐Ÿ”ด Multi-Skill Deficient" -n "๐ŸŸข Progressing Nicely" +`) +} + +// Note: listProfiles is defined after TEST_PROFILES (below) + import { createId } from '@paralleldrive/cuid2' import { desc, eq } from 'drizzle-orm' import { db, schema } from '../src/db' @@ -1167,8 +1215,142 @@ Use this profile to verify: }, ], }, + { + name: '๐ŸงŠ Forgotten Weaknesses', + emoji: '๐ŸงŠ', + color: '#3b82f6', // blue-500 + category: 'edge', + description: 'EDGE CASE - Weak skills that are also stale (urgent remediation needed)', + currentPhaseId: 'L1.add.+2.five', + practicingSkills: [ + 'basic.directAddition', + 'basic.heavenBead', + 'basic.simpleCombinations', + 'fiveComplements.4=5-1', + 'fiveComplements.3=5-2', + 'fiveComplements.2=5-3', + ], + tutorialCompletedSkills: [ + 'basic.directAddition', + 'basic.heavenBead', + 'basic.simpleCombinations', + 'fiveComplements.4=5-1', + 'fiveComplements.3=5-2', + 'fiveComplements.2=5-3', + ], + intentionNotes: `INTENTION: Forgotten Weaknesses + +This student has a realistic mix of weak and stale skills - NOT the same set. + +Session Mode: Should trigger REMEDIATION. + +Skill breakdown: +โ€ข 1 skill STRONG + recent (healthy baseline) +โ€ข 1 skill STRONG + stale 20 days (stale-only, should refresh easily) +โ€ข 1 skill WEAK + recent (weak-only, actively struggling) +โ€ข 1 skill WEAK + stale 14 days (overlap: weak AND stale) +โ€ข 1 skill WEAK + stale 35 days (overlap: urgent forgotten weakness) +โ€ข 1 skill DEVELOPING + stale 25 days (borderline, needs attention) + +This tests: +โ€ข Different combinations of weak/stale indicators +โ€ข UI distinguishing "stale but strong" from "stale AND weak" +โ€ข Session planning prioritizing weak+stale over strong+stale +โ€ข BKT decay effects on skills at different mastery levels + +Real-world scenario: Student has been practicing inconsistently. Some skills +are rusty from neglect (stale), others they just can't get (weak), and some +are both - the forgotten weaknesses that need urgent attention.`, + skillHistory: [ + // STRONG + recent (healthy baseline) + { skillId: 'basic.directAddition', targetAccuracy: 0.92, problems: 20, ageDays: 1 }, + // STRONG + stale 20 days (stale-only - "Getting rusty" but should be fine) + { skillId: 'basic.heavenBead', targetAccuracy: 0.88, problems: 18, ageDays: 20 }, + // WEAK + recent (weak-only - actively struggling with this) + { skillId: 'basic.simpleCombinations', targetAccuracy: 0.28, problems: 15, ageDays: 2 }, + // WEAK + stale 14 days (overlap: weak AND "Not practiced recently") + { skillId: 'fiveComplements.4=5-1', targetAccuracy: 0.32, problems: 14, ageDays: 14 }, + // WEAK + stale 35 days (overlap: urgent - weak AND "Very stale") + { skillId: 'fiveComplements.3=5-2', targetAccuracy: 0.22, problems: 18, ageDays: 35 }, + // DEVELOPING + stale 25 days (borderline - needs practice) + { skillId: 'fiveComplements.2=5-3', targetAccuracy: 0.55, problems: 16, ageDays: 25 }, + ], + }, ] +// ============================================================================= +// CLI Helper Functions +// ============================================================================= + +function listProfiles(): void { + console.log('\n๐Ÿ“‹ Available Test Students:\n') + + const categories: Record = { + bkt: [], + session: [], + edge: [], + } + + for (const profile of TEST_PROFILES) { + categories[profile.category].push(profile) + } + + console.log('BKT Scenarios (--category bkt):') + for (const p of categories.bkt) { + console.log(` ${p.name}`) + console.log(` ${p.description}`) + } + + console.log('\nSession Mode Tests (--category session):') + for (const p of categories.session) { + console.log(` ${p.name}`) + console.log(` ${p.description}`) + } + + console.log('\nEdge Cases (--category edge):') + for (const p of categories.edge) { + console.log(` ${p.name}`) + console.log(` ${p.description}`) + } + + console.log(`\nTotal: ${TEST_PROFILES.length} students\n`) +} + +/** + * Filter profiles based on CLI args (name and category filters) + */ +function filterProfiles(profiles: TestStudentProfile[]): TestStudentProfile[] { + const names = cliArgs.name as string[] + const categories = cliArgs.category as string[] + + // If no filters, return all + if (names.length === 0 && categories.length === 0) { + return profiles + } + + return profiles.filter((profile) => { + // Check name filter (partial match, case-insensitive) + const matchesName = + names.length === 0 || + names.some( + (n) => + profile.name.toLowerCase().includes(n.toLowerCase()) || + n.toLowerCase().includes(profile.name.toLowerCase()) + ) + + // Check category filter + const matchesCategory = categories.length === 0 || categories.includes(profile.category) + + // If both filters specified, must match at least one + if (names.length > 0 && categories.length > 0) { + return matchesName || matchesCategory + } + + // If only one filter type, must match that one + return matchesName && matchesCategory + }) +} + // ============================================================================= // Helpers // ============================================================================= @@ -1727,8 +1909,50 @@ async function createTestStudentWithTuning( // ============================================================================= async function main() { + // Handle --help + if (cliArgs.help) { + showHelp() + process.exit(0) + } + + // Handle --list + if (cliArgs.list) { + listProfiles() + process.exit(0) + } + + // Filter profiles based on CLI args + const profilesToSeed = filterProfiles(TEST_PROFILES) + + if (profilesToSeed.length === 0) { + console.log('โŒ No students match the specified filters.') + console.log(' Use --list to see available students.') + process.exit(1) + } + + // Handle --dry-run + if (cliArgs['dry-run']) { + console.log('๐Ÿงช DRY RUN - Would seed the following students:\n') + for (const profile of profilesToSeed) { + console.log(` ${profile.name} [${profile.category}]`) + console.log(` ${profile.description}`) + } + console.log(`\nTotal: ${profilesToSeed.length} students`) + process.exit(0) + } + console.log('๐Ÿงช Seeding Test Students for BKT Testing...\n') + // Show filter info if applicable + const names = cliArgs.name as string[] + const categories = cliArgs.category as string[] + if (names.length > 0 || categories.length > 0) { + console.log(` Filtering: ${profilesToSeed.length} of ${TEST_PROFILES.length} students`) + if (names.length > 0) console.log(` Names: ${names.join(', ')}`) + if (categories.length > 0) console.log(` Categories: ${categories.join(', ')}`) + console.log('') + } + // Find the most recent browser session by looking at recent session activity // This is more reliable than player creation time console.log('1. Finding most recent browser session...') @@ -1800,7 +2024,7 @@ async function main() { // Create each test profile with iterative tuning (up to 3 rounds) console.log('\n2. Creating test students (with up to 2 tuning rounds if needed)...\n') - for (const profile of TEST_PROFILES) { + for (const profile of profilesToSeed) { const { playerId, classifications, tuningHistory } = await createTestStudentWithTuning( profile, userId, @@ -1808,7 +2032,7 @@ async function main() { ) const { weak, developing, strong } = classifications - console.log(` ${profile.emoji} ${profile.name}`) + console.log(` ${profile.name}`) console.log(` ${profile.description}`) console.log(` Phase: ${profile.currentPhaseId}`) console.log(` Practicing: ${profile.practicingSkills.length} skills`) diff --git a/apps/web/src/components/decomposition/README.md b/apps/web/src/components/decomposition/README.md index d140c0b1..55c5ae14 100644 --- a/apps/web/src/components/decomposition/README.md +++ b/apps/web/src/components/decomposition/README.md @@ -159,14 +159,14 @@ function TutorialPlayer({ step, currentMultiStep }) { } ``` -### Practice Help Panel +### With Help Mode ```typescript import { DecompositionProvider, DecompositionDisplay } from '@/components/decomposition' -function PracticeHelpPanel({ currentValue, targetValue, helpLevel }) { - // Show decomposition at help level 2+ - if (helpLevel < 2) return null +function HelpOverlay({ currentValue, targetValue, showHelp }) { + // Show decomposition when help is requested + if (!showHelp) return null return ( | null, - currentStepIndex: number, - abacusValue: number, - targetValue: number -): string { - if (!sequence || sequence.steps.length === 0) { - return 'Take your time and think through each step.' - } - - // Check if we're done - if (abacusValue === targetValue) { - return 'You did it! Move on to the next step.' - } - - // Get the current step - const currentStep = sequence.steps[currentStepIndex] - if (!currentStep) { - return 'Take your time and think through each step.' - } - - // Find the segment this step belongs to - const segment = sequence.segments.find((s) => s.id === currentStep.segmentId) - - // Use the segment's readable summary if available - if (segment?.readable?.summary) { - return segment.readable.summary - } - - // Fall back to generating from the rule - if (segment) { - const rule = segment.plan[0]?.rule - switch (rule) { - case 'Direct': - return `Add ${segment.digit} directly to the ${getPlaceName(segment.place)} column.` - case 'FiveComplement': - return `Think about friends of 5. What plus ${5 - segment.digit} makes 5?` - case 'TenComplement': - return `Think about friends of 10. What plus ${10 - segment.digit} makes 10?` - case 'Cascade': - return 'This will carry through multiple columns. Start from the left.' - default: - break - } - } - - // Fall back to english instruction from the step - if (currentStep.englishInstruction) { - return currentStep.englishInstruction - } - - return 'Think about which beads need to move.' -} - -/** - * Get place name from place value - */ -function getPlaceName(place: number): string { - switch (place) { - case 0: - return 'ones' - case 1: - return 'tens' - case 2: - return 'hundreds' - case 3: - return 'thousands' - default: - return `10^${place}` - } -} - -interface PracticeHelpPanelProps { - /** Current help state from usePracticeHelp hook */ - helpState: PracticeHelpState - /** Request help at a specific level */ - onRequestHelp: (level?: HelpLevel) => void - /** Dismiss help (return to L0) */ - onDismissHelp: () => void - /** Whether this is the abacus part (enables bead arrows at L3) */ - isAbacusPart?: boolean - /** Current value on the abacus (for bead arrows at L3) */ - currentValue?: number - /** Target value to reach (for bead arrows at L3) */ - targetValue?: number -} - -/** - * Help level labels for display - */ -const HELP_LEVEL_LABELS: Record = { - 0: 'No Help', - 1: 'Hint', - 2: 'Show Steps', - 3: 'Show Beads', -} - -/** - * Help level icons - */ -const HELP_LEVEL_ICONS: Record = { - 0: '๐Ÿ’ก', - 1: '๐Ÿ’ฌ', - 2: '๐Ÿ“', - 3: '๐Ÿงฎ', -} - -/** - * PracticeHelpPanel - Progressive help display for practice sessions - * - * Shows escalating levels of help: - * - L0: Just the "Need Help?" button - * - L1: Coach hint (verbal guidance) - * - L2: Mathematical decomposition with explanations - * - L3: Bead movement arrows (for abacus part) - */ -export function PracticeHelpPanel({ - helpState, - onRequestHelp, - onDismissHelp, - isAbacusPart = false, - currentValue, - targetValue, -}: PracticeHelpPanelProps) { - const { resolvedTheme } = useTheme() - const isDark = resolvedTheme === 'dark' - const { currentLevel, content, isAvailable, maxLevelUsed } = helpState - const [isExpanded, setIsExpanded] = useState(false) - - // Track current abacus value for step synchronization - const [abacusValue, setAbacusValue] = useState(currentValue ?? 0) - - // Generate the decomposition steps to determine current step from abacus value - const sequence = useMemo(() => { - if (currentValue === undefined || targetValue === undefined) return null - return generateUnifiedInstructionSequence(currentValue, targetValue) - }, [currentValue, targetValue]) - - // Calculate which step the user is on based on abacus value - // Find the highest step index where expectedValue <= abacusValue - const currentStepIndex = useMemo(() => { - if (!sequence || sequence.steps.length === 0) return 0 - if (currentValue === undefined) return 0 - - // Start value is the value before any steps - const startVal = currentValue - - // If abacus is still at start value, we're at step 0 - if (abacusValue === startVal) return 0 - - // Find which step we're on by checking expected values - // The step index to highlight is the one we're working toward (next incomplete step) - for (let i = 0; i < sequence.steps.length; i++) { - const step = sequence.steps[i] - // If abacus value is less than this step's expected value, we're working on this step - if (abacusValue < step.expectedValue) { - return i - } - // If we've reached or passed this step's expected value, check next step - if (abacusValue === step.expectedValue) { - // We've completed this step, move to next - return Math.min(i + 1, sequence.steps.length - 1) - } - } - - // At or past target - show last step as complete - return sequence.steps.length - 1 - }, [sequence, abacusValue, currentValue]) - - // Generate dynamic coach hint based on current step - const dynamicCoachHint = useMemo(() => { - return generateDynamicCoachHint(sequence, currentStepIndex, abacusValue, targetValue ?? 0) - }, [sequence, currentStepIndex, abacusValue, targetValue]) - - // Handle abacus value changes - const handleAbacusValueChange = useCallback((newValue: number) => { - setAbacusValue(newValue) - }, []) - - // Calculate effective level here so handleRequestHelp can use it - // (effectiveLevel treats L0 as L1 since we auto-show help on prefix sum detection) - const effectiveLevel = currentLevel === 0 ? 1 : currentLevel - - const handleRequestHelp = useCallback(() => { - // Always request the next level above effectiveLevel - if (effectiveLevel < 3) { - onRequestHelp((effectiveLevel + 1) as HelpLevel) - setIsExpanded(true) - } - }, [effectiveLevel, onRequestHelp]) - - const handleDismiss = useCallback(() => { - onDismissHelp() - setIsExpanded(false) - }, [onDismissHelp]) - - // Don't render if help is not available (e.g., sequence generation failed) - if (!isAvailable) { - return null - } - - // Levels 1-3: Show the help content (effectiveLevel is calculated above) - return ( -
- {/* Header with level indicator */} -
-
- {HELP_LEVEL_ICONS[effectiveLevel]} - - {HELP_LEVEL_LABELS[effectiveLevel]} - - {/* Help level indicator dots */} -
- {[1, 2, 3].map((level) => ( -
- ))} -
-
- - -
- - {/* Level 1: Coach hint - uses dynamic hint that updates with abacus progress */} - {effectiveLevel >= 1 && dynamicCoachHint && ( -
-

- {dynamicCoachHint} -

-
- )} - - {/* Level 2: Decomposition */} - {effectiveLevel >= 2 && - content?.decomposition && - content.decomposition.isMeaningful && - currentValue !== undefined && - targetValue !== undefined && ( -
-
- Step-by-Step -
-
- - - -
-
- )} - - {/* Level 3: Visual abacus with bead arrows */} - {effectiveLevel >= 3 && currentValue !== undefined && targetValue !== undefined && ( -
-
- ๐Ÿงฎ Follow the Arrows -
- - - - {isAbacusPart && ( -
- Try following these movements on your physical abacus -
- )} -
- )} - - {/* Fallback: Text bead steps if abacus values not provided */} - {effectiveLevel >= 3 && - (currentValue === undefined || targetValue === undefined) && - content?.beadSteps && - content.beadSteps.length > 0 && ( -
-
- Bead Movements -
-
    - {content.beadSteps.map((step, index) => ( -
  1. - - {step.mathematicalTerm} - - {step.englishInstruction && ( - - {' '} - โ€” {step.englishInstruction} - - )} -
  2. - ))} -
-
- )} - - {/* More help button (if not at max level) */} - {effectiveLevel < 3 && ( - - )} - - {/* Max level indicator */} - {maxLevelUsed > 0 && ( -
- Help used: Level {maxLevelUsed} -
- )} -
- ) -} - -export default PracticeHelpPanel diff --git a/apps/web/src/components/practice/ProgressDashboard.stories.tsx b/apps/web/src/components/practice/ProgressDashboard.stories.tsx index 84bda0ec..42dfdbba 100644 --- a/apps/web/src/components/practice/ProgressDashboard.stories.tsx +++ b/apps/web/src/components/practice/ProgressDashboard.stories.tsx @@ -277,47 +277,6 @@ export const DifferentStudents: Story = { ), } -/** - * With Focus Areas - showing skills needing reinforcement - */ -export const WithFocusAreas: Story = { - render: () => ( - - alert(`Clear reinforcement for ${skillId}`)} - onClearAllReinforcement={() => alert('Clear all reinforcement')} - {...handlers} - /> - - ), -} - // Note: Active session resume/start functionality has been moved to the // SessionModeBanner system (see ActiveSessionBanner.tsx and ProjectingBanner.tsx) diff --git a/apps/web/src/components/practice/ProgressDashboard.tsx b/apps/web/src/components/practice/ProgressDashboard.tsx index e4ab515c..107e314c 100644 --- a/apps/web/src/components/practice/ProgressDashboard.tsx +++ b/apps/web/src/components/practice/ProgressDashboard.tsx @@ -18,12 +18,8 @@ export interface SkillProgress { attempts: number correct: number consecutiveCorrect: number - /** Whether this skill needs reinforcement (used heavy help recently) */ - needsReinforcement?: boolean - /** Last help level used on this skill (0-3) */ + /** Last help level used on this skill (0 or 1) */ lastHelpLevel?: number - /** Progress toward clearing reinforcement (0-3) */ - reinforcementStreak?: number } /** @@ -76,14 +72,8 @@ interface ProgressDashboardProps { currentPhase?: CurrentPhaseInfo /** BKT-based skill health summary */ skillHealth?: SkillHealthSummary - /** Skills that need extra practice (used heavy help recently) */ - focusAreas?: SkillProgress[] /** Callback when no active session - start new practice */ onStartPractice: () => void - /** Callback to clear reinforcement for a skill (teacher only) */ - onClearReinforcement?: (skillId: string) => void - /** Callback to clear all reinforcement flags (teacher only) */ - onClearAllReinforcement?: () => void } // Helper: Compute progress percent based on mode @@ -150,10 +140,7 @@ export function ProgressDashboard({ student, currentPhase, skillHealth, - focusAreas = [], onStartPractice, - onClearReinforcement, - onClearAllReinforcement, }: ProgressDashboardProps) { const { resolvedTheme } = useTheme() const isDark = resolvedTheme === 'dark' @@ -425,146 +412,6 @@ export function ProgressDashboard({

)}
- - {/* Focus Areas - Skills needing extra practice */} - {focusAreas.length > 0 && ( -
-
-

- ๐ŸŽฏ - Focus Areas -

- {onClearAllReinforcement && focusAreas.length > 1 && ( - - )} -
-

- These skills need extra practice: -

-
- {focusAreas.map((skill) => ( -
-
- - {skill.skillName} - - {skill.reinforcementStreak !== undefined && skill.reinforcementStreak > 0 && ( - - ({skill.reinforcementStreak}/3) - - )} -
- {onClearReinforcement && ( - - )} -
- ))} -
-
- )} ) } diff --git a/apps/web/src/components/practice/SkillPerformanceReports.tsx b/apps/web/src/components/practice/SkillPerformanceReports.tsx index 535b8555..0dc8180b 100644 --- a/apps/web/src/components/practice/SkillPerformanceReports.tsx +++ b/apps/web/src/components/practice/SkillPerformanceReports.tsx @@ -19,7 +19,6 @@ interface SkillPerformanceAnalysis { fastSkills: SkillPerformance[] slowSkills: SkillPerformance[] lowAccuracySkills: SkillPerformance[] - reinforcementSkills: SkillPerformance[] } interface SkillPerformanceReportsProps { @@ -291,7 +290,6 @@ export function SkillPerformanceReports({ const hasTimingData = analysis.skills.some((s) => s.responseTimeCount > 0) const hasSlowSkills = analysis.slowSkills.length > 0 const hasLowAccuracySkills = analysis.lowAccuracySkills.length > 0 - const hasReinforcementSkills = analysis.reinforcementSkills.length > 0 // No data yet if (analysis.skills.length === 0) { @@ -376,7 +374,7 @@ export function SkillPerformanceReports({ )} {/* Skills appearing frequently in errors or slow responses */} - {(hasSlowSkills || hasLowAccuracySkills || hasReinforcementSkills) && ( + {(hasSlowSkills || hasLowAccuracySkills) && (

))} - {analysis.reinforcementSkills - .filter( - (s) => - !analysis.slowSkills.some((slow) => slow.skillId === s.skillId) && - !analysis.lowAccuracySkills.some((low) => low.skillId === s.skillId) - ) - .map((skill) => ( - - ))}

)} diff --git a/apps/web/src/components/practice/sessionSummaryUtils.ts b/apps/web/src/components/practice/sessionSummaryUtils.ts index 64da6e66..f4c8ec02 100644 --- a/apps/web/src/components/practice/sessionSummaryUtils.ts +++ b/apps/web/src/components/practice/sessionSummaryUtils.ts @@ -104,8 +104,8 @@ export function filterProblemsNeedingAttention( reasons.push('slow') } - // Check if used significant help - if (problem.result.helpLevelUsed >= 3) { + // Check if used help (helpLevelUsed is binary: 0 = no help, 1 = help used) + if (problem.result.helpLevelUsed >= 1) { reasons.push('help-used') } diff --git a/apps/web/src/db/schema/player-skill-mastery.ts b/apps/web/src/db/schema/player-skill-mastery.ts index c8ca2295..20650f24 100644 --- a/apps/web/src/db/schema/player-skill-mastery.ts +++ b/apps/web/src/db/schema/player-skill-mastery.ts @@ -56,32 +56,10 @@ export const playerSkillMastery = sqliteTable( .notNull() .$defaultFn(() => new Date()), - // ---- Reinforcement Tracking (for help system feedback loop) ---- - /** - * Whether this skill needs reinforcement - * Set to true when student uses heavy help (level 2+) or has multiple incorrect attempts - * Cleared after N consecutive correct answers without help - */ - needsReinforcement: integer('needs_reinforcement', { mode: 'boolean' }) - .notNull() - .default(false), - - /** - * Last help level used on this skill (0-3) - * Used to track struggling patterns + * Last help level used on this skill (0 = no help, 1 = used help) */ lastHelpLevel: integer('last_help_level').notNull().default(0), - - /** - * Consecutive correct answers without heavy help since reinforcement was flagged - * Resets to 0 when reinforcement is cleared or when help level 2+ is used - */ - reinforcementStreak: integer('reinforcement_streak').notNull().default(0), - - // NOTE: totalResponseTimeMs, responseTimeCount columns REMOVED - // These are now computed on-the-fly from session results (single source of truth) - // See: getRecentSessionResults() in session-planner.ts }, (table) => ({ /** Index for fast lookups by playerId */ diff --git a/apps/web/src/db/schema/players.ts b/apps/web/src/db/schema/players.ts index d025687b..12ca7ef1 100644 --- a/apps/web/src/db/schema/players.ts +++ b/apps/web/src/db/schema/players.ts @@ -4,7 +4,7 @@ import { users } from './users' /** * Help mode for practice sessions - * - 'auto': Help automatically appears after time thresholds + * - 'auto': Help automatically appears after timeout * - 'manual': Help only appears when student clicks for it * - 'teacher-approved': Student can request help, but teacher must approve */ @@ -12,22 +12,18 @@ export type HelpMode = 'auto' | 'manual' | 'teacher-approved' /** * Settings that control help behavior during practice sessions + * + * Note: Help is now boolean (used or not used). BKT uses 0.5x evidence weight + * for problems where help was used. */ export interface StudentHelpSettings { /** How help is triggered */ helpMode: HelpMode - /** For 'auto' mode: milliseconds before each help level appears */ - autoEscalationTimingMs: { - level1: number // Default: 30000 (30s) - level2: number // Default: 60000 (60s) - level3: number // Default: 90000 (90s) - } - - /** For beginners: unlimited L1-L2 help without mastery penalty */ + /** For beginners: help doesn't count against mastery */ beginnerFreeHelp: boolean - /** For advanced: L2+ help requires teacher approval */ + /** For advanced students: help requires teacher approval */ advancedRequiresApproval: boolean } @@ -36,11 +32,6 @@ export interface StudentHelpSettings { */ export const DEFAULT_HELP_SETTINGS: StudentHelpSettings = { helpMode: 'auto', - autoEscalationTimingMs: { - level1: 30000, - level2: 60000, - level3: 90000, - }, beginnerFreeHelp: true, advancedRequiresApproval: false, } diff --git a/apps/web/src/db/schema/session-plans.ts b/apps/web/src/db/schema/session-plans.ts index e041471b..33101ee3 100644 --- a/apps/web/src/db/schema/session-plans.ts +++ b/apps/web/src/db/schema/session-plans.ts @@ -214,13 +214,14 @@ export interface SessionAdjustment { } /** - * Help level used during a problem + * Help level used during a problem (boolean) * - 0: No help requested - * - 1: Coach hint only (e.g., "Add the tens digit first") - * - 2: Decomposition shown (e.g., "45 + 27 = 45 + 20 + 7") - * - 3: Bead highlighting (arrows showing which beads to move) + * - 1: Help was used (interactive abacus overlay shown) + * + * Note: The system previously defined levels 0-3, but only 0/1 are ever recorded. + * BKT uses conjunctive blame attribution to identify weak skills. */ -export type HelpLevel = 0 | 1 | 2 | 3 +export type HelpLevel = 0 | 1 /** * Result of a single problem slot diff --git a/apps/web/src/hooks/usePracticeHelp.ts b/apps/web/src/hooks/usePracticeHelp.ts deleted file mode 100644 index 2deb79b8..00000000 --- a/apps/web/src/hooks/usePracticeHelp.ts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * Practice Help Hook - * - * Manages progressive help during practice sessions. - * Integrates with the tutorial system to provide escalating levels of assistance. - * - * Help Levels: - * - L0: No help - student is working independently - * - L1: Coach hint - verbal encouragement ("Think about what makes 10") - * - L2: Decomposition - show the mathematical breakdown - * - L3: Bead arrows - highlight specific bead movements - */ - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import type { HelpLevel } from '@/db/schema/session-plans' -import type { StudentHelpSettings } from '@/db/schema/players' -import { - generateUnifiedInstructionSequence, - type PedagogicalSegment, - type UnifiedInstructionSequence, - type UnifiedStepData, -} from '@/utils/unifiedStepGenerator' -import { extractSkillsFromSequence, type ExtractedSkill } from '@/utils/skillExtraction' - -/** - * Current term context for help - */ -export interface TermContext { - /** Current abacus value before this term */ - currentValue: number - /** Target value after adding this term */ - targetValue: number - /** The term being added (difference) */ - term: number - /** Index of this term in the problem */ - termIndex: number -} - -/** - * Help content at different levels - */ -export interface HelpContent { - /** Level 1: Coach hint - short verbal guidance */ - coachHint: string - /** Level 2: Decomposition string and explanation */ - decomposition: { - /** Full decomposition string (e.g., "3 + 7 = 3 + (10 - 3) = 10") */ - fullDecomposition: string - /** Whether this decomposition is pedagogically meaningful */ - isMeaningful: boolean - /** Segments with readable explanations */ - segments: PedagogicalSegment[] - } - /** Level 3: Step-by-step bead movements */ - beadSteps: UnifiedStepData[] - /** Skills exercised in this term */ - skills: ExtractedSkill[] - /** The underlying instruction sequence */ - sequence: UnifiedInstructionSequence | null -} - -/** - * Help state returned by the hook - */ -export interface PracticeHelpState { - /** Current help level (0-3) */ - currentLevel: HelpLevel - /** Help content for the current term */ - content: HelpContent | null - /** Maximum help level used so far for this term */ - maxLevelUsed: HelpLevel - /** Whether help is available (sequence generated successfully) */ - isAvailable: boolean - /** Time since help became available (for auto-escalation) */ - elapsedTimeMs: number - /** How help was triggered */ - trigger: 'none' | 'manual' | 'auto-time' | 'auto-errors' -} - -/** - * Actions for managing help - */ -export interface PracticeHelpActions { - /** Request help at a specific level (or next level if not specified) */ - requestHelp: (level?: HelpLevel) => void - /** Escalate to the next help level */ - escalateHelp: () => void - /** Reset help state for a new term */ - resetForNewTerm: (context: TermContext) => void - /** Dismiss current help (return to L0) */ - dismissHelp: () => void - /** Mark that an error occurred (for auto-escalation) */ - recordError: () => void -} - -/** - * Configuration for help behavior - */ -export interface PracticeHelpConfig { - /** Student's help settings */ - settings: StudentHelpSettings - /** Whether this student is a beginner (free help without penalty) */ - isBeginnerMode: boolean - /** BKT-based skill classification of the student (affects auto-escalation) */ - studentBktClassification?: 'strong' | 'developing' | 'weak' | null - /** Callback when help level changes (for tracking) */ - onHelpLevelChange?: (level: HelpLevel, trigger: PracticeHelpState['trigger']) => void - /** Callback when max help level updates */ - onMaxLevelUpdate?: (maxLevel: HelpLevel) => void -} - -const DEFAULT_SETTINGS: StudentHelpSettings = { - helpMode: 'auto', - autoEscalationTimingMs: { - level1: 30000, - level2: 60000, - level3: 90000, - }, - beginnerFreeHelp: true, - advancedRequiresApproval: false, -} - -/** - * Generate coach hint based on the pedagogical segment - */ -function generateCoachHint(segment: PedagogicalSegment | undefined): string { - if (!segment) { - return 'Take your time and think through each step.' - } - - const rule = segment.plan[0]?.rule - const readable = segment.readable - - switch (rule) { - case 'Direct': - return ( - readable.summary || - `You can add ${segment.digit} directly to the ${readable.title.split(' โ€” ')[1] || 'column'}.` - ) - case 'FiveComplement': - return `Think about friends of 5. What plus ${5 - segment.digit} makes 5?` - case 'TenComplement': - return `Think about friends of 10. What plus ${10 - segment.digit} makes 10?` - case 'Cascade': - return 'This will carry through multiple columns. Start from the left.' - default: - return 'Think about which beads need to move.' - } -} - -/** - * Hook for managing progressive help during practice - */ -export function usePracticeHelp( - config: PracticeHelpConfig -): [PracticeHelpState, PracticeHelpActions] { - const { - settings = DEFAULT_SETTINGS, - isBeginnerMode, - onHelpLevelChange, - onMaxLevelUpdate, - } = config - - // Current term context - const [termContext, setTermContext] = useState(null) - - // Help state - const [currentLevel, setCurrentLevel] = useState(0) - const [maxLevelUsed, setMaxLevelUsed] = useState(0) - const [trigger, setTrigger] = useState('none') - const [errorCount, setErrorCount] = useState(0) - - // Timer for auto-escalation - const [startTime, setStartTime] = useState(null) - const [elapsedTimeMs, setElapsedTimeMs] = useState(0) - const timerRef = useRef | null>(null) - - // Generate instruction sequence for current term - const sequence = useMemo(() => { - if (!termContext) return null - try { - return generateUnifiedInstructionSequence(termContext.currentValue, termContext.targetValue) - } catch { - // Subtraction or other unsupported operations - return null - } - }, [termContext]) - - // Extract skills from sequence - const skills = useMemo(() => { - if (!sequence) return [] - return extractSkillsFromSequence(sequence) - }, [sequence]) - - // Build help content - const content = useMemo(() => { - if (!sequence) return null - - const firstSegment = sequence.segments[0] - - return { - coachHint: generateCoachHint(firstSegment), - decomposition: { - fullDecomposition: sequence.fullDecomposition, - isMeaningful: sequence.isMeaningfulDecomposition, - segments: sequence.segments, - }, - beadSteps: sequence.steps, - skills, - sequence, - } - }, [sequence, skills]) - - // Check if help is available - const isAvailable = sequence !== null - - // Start/stop timer for elapsed time tracking - useEffect(() => { - if (termContext && startTime === null) { - setStartTime(Date.now()) - } - - // Update elapsed time every second - if (startTime !== null) { - timerRef.current = setInterval(() => { - setElapsedTimeMs(Date.now() - startTime) - }, 1000) - } - - return () => { - if (timerRef.current) { - clearInterval(timerRef.current) - } - } - }, [termContext, startTime]) - - // Auto-escalation based on time (only in 'auto' mode) - useEffect(() => { - if (settings.helpMode !== 'auto' || !isAvailable) return - - const { autoEscalationTimingMs } = settings - - // Check if we should auto-escalate - if (currentLevel === 0 && elapsedTimeMs >= autoEscalationTimingMs.level1) { - setCurrentLevel(1) - setTrigger('auto-time') - if (1 > maxLevelUsed) { - setMaxLevelUsed(1) - onMaxLevelUpdate?.(1) - } - onHelpLevelChange?.(1, 'auto-time') - } else if (currentLevel === 1 && elapsedTimeMs >= autoEscalationTimingMs.level2) { - setCurrentLevel(2) - setTrigger('auto-time') - if (2 > maxLevelUsed) { - setMaxLevelUsed(2) - onMaxLevelUpdate?.(2) - } - onHelpLevelChange?.(2, 'auto-time') - } else if (currentLevel === 2 && elapsedTimeMs >= autoEscalationTimingMs.level3) { - setCurrentLevel(3) - setTrigger('auto-time') - if (3 > maxLevelUsed) { - setMaxLevelUsed(3) - onMaxLevelUpdate?.(3) - } - onHelpLevelChange?.(3, 'auto-time') - } - }, [ - elapsedTimeMs, - currentLevel, - settings.helpMode, - settings.autoEscalationTimingMs, - isAvailable, - maxLevelUsed, - onHelpLevelChange, - onMaxLevelUpdate, - ]) - - // Auto-escalation based on errors - useEffect(() => { - if (settings.helpMode !== 'auto' || !isAvailable) return - - // After 2 errors, escalate to L1 - // After 3 errors, escalate to L2 - // After 4 errors, escalate to L3 - let targetLevel: HelpLevel = 0 - if (errorCount >= 4) { - targetLevel = 3 - } else if (errorCount >= 3) { - targetLevel = 2 - } else if (errorCount >= 2) { - targetLevel = 1 - } - - if (targetLevel > currentLevel) { - setCurrentLevel(targetLevel) - setTrigger('auto-errors') - if (targetLevel > maxLevelUsed) { - setMaxLevelUsed(targetLevel) - onMaxLevelUpdate?.(targetLevel) - } - onHelpLevelChange?.(targetLevel, 'auto-errors') - } - }, [ - errorCount, - currentLevel, - settings.helpMode, - isAvailable, - maxLevelUsed, - onHelpLevelChange, - onMaxLevelUpdate, - ]) - - // Actions - const requestHelp = useCallback( - (level?: HelpLevel) => { - if (!isAvailable) return - - const targetLevel = level ?? (Math.min(currentLevel + 1, 3) as HelpLevel) - - // Check if advanced help requires approval - if (!isBeginnerMode && settings.advancedRequiresApproval && targetLevel >= 2) { - // In teacher-approved mode, this would trigger an approval request - // For now, we just don't escalate past L1 automatically - if (settings.helpMode === 'teacher-approved' && targetLevel > 1) { - // TODO: Trigger approval request - return - } - } - - setCurrentLevel(targetLevel) - setTrigger('manual') - if (targetLevel > maxLevelUsed) { - setMaxLevelUsed(targetLevel) - onMaxLevelUpdate?.(targetLevel) - } - onHelpLevelChange?.(targetLevel, 'manual') - }, - [ - currentLevel, - isAvailable, - isBeginnerMode, - settings, - maxLevelUsed, - onHelpLevelChange, - onMaxLevelUpdate, - ] - ) - - const escalateHelp = useCallback(() => { - if (currentLevel < 3) { - requestHelp((currentLevel + 1) as HelpLevel) - } - }, [currentLevel, requestHelp]) - - const resetForNewTerm = useCallback((context: TermContext) => { - setTermContext(context) - setCurrentLevel(0) - setMaxLevelUsed(0) - setTrigger('none') - setErrorCount(0) - setStartTime(null) - setElapsedTimeMs(0) - }, []) - - const dismissHelp = useCallback(() => { - setCurrentLevel(0) - setTrigger('none') - }, []) - - const recordError = useCallback(() => { - setErrorCount((prev) => prev + 1) - }, []) - - const state: PracticeHelpState = { - currentLevel, - content, - maxLevelUsed, - isAvailable, - elapsedTimeMs, - trigger, - } - - const actions: PracticeHelpActions = { - requestHelp, - escalateHelp, - resetForNewTerm, - dismissHelp, - recordError, - } - - return [state, actions] -} - -export default usePracticeHelp diff --git a/apps/web/src/lib/curriculum/bkt/evidence-quality.ts b/apps/web/src/lib/curriculum/bkt/evidence-quality.ts index 71fd14e4..4aedf9b4 100644 --- a/apps/web/src/lib/curriculum/bkt/evidence-quality.ts +++ b/apps/web/src/lib/curriculum/bkt/evidence-quality.ts @@ -3,31 +3,26 @@ * * Not all observations are equally informative. We adjust the weight * of evidence based on: - * - Help level: More help = less confident the student really knows it + * - Help level: Using help = less confident the student really knows it * - Response time: Fast correct = strong mastery, slow correct = struggled */ +import type { HelpLevel } from '@/db/schema/session-plans' + /** - * Adjust observation weight based on help level. - * More help = less confident the student really knows it. + * Adjust observation weight based on whether help was used. + * Using help = less confident the student really knows it. * - * @param helpLevel - Amount of help provided (0 = none, 3 = full solution) - * @returns Weight multiplier [0, 1] + * @param helpLevel - 0 = no help, 1 = help used + * @returns Weight multiplier [0.5, 1.0] */ -export function helpLevelWeight(helpLevel: 0 | 1 | 2 | 3): number { - switch (helpLevel) { - case 0: - return 1.0 // No help - full evidence - case 1: - return 0.8 // Minor hint - slight reduction - case 2: - return 0.5 // Significant help - halve evidence - case 3: - return 0.5 // Full help - halve evidence - default: - // Guard against unexpected values (e.g., null, undefined, or invalid numbers from JSON parsing) - return 1.0 +export function helpLevelWeight(helpLevel: HelpLevel): number { + // Guard against unexpected values (legacy data, JSON parsing issues) + if (helpLevel !== 0 && helpLevel !== 1) { + return 1.0 } + // 0 = no help (full evidence), 1 = used help (50% evidence) + return helpLevel === 0 ? 1.0 : 0.5 } /** @@ -75,7 +70,7 @@ export function responseTimeWeight( * Combined evidence weight from help and response time. */ export function combinedEvidenceWeight( - helpLevel: 0 | 1 | 2 | 3, + helpLevel: HelpLevel, responseTimeMs: number, isCorrect: boolean, expectedTimeMs: number = 5000 diff --git a/apps/web/src/lib/curriculum/config/index.ts b/apps/web/src/lib/curriculum/config/index.ts index 1c15c071..814d584c 100644 --- a/apps/web/src/lib/curriculum/config/index.ts +++ b/apps/web/src/lib/curriculum/config/index.ts @@ -10,7 +10,6 @@ * - slot-distribution.ts - Problem distribution across purposes and parts * - complexity-budgets.ts - Cognitive load budget system * - skill-costs.ts - Base skill complexity and rotation multipliers - * - reinforcement-config.ts - Help system feedback loop rules * - bkt-integration.ts - Bayesian Knowledge Tracing integration */ @@ -49,12 +48,6 @@ export { ROTATION_MULTIPLIERS, } from './skill-costs' -// Reinforcement System (help feedback loop) -export { - REINFORCEMENT_CONFIG, - type ReinforcementConfig, -} from './reinforcement-config' - // BKT Integration export { // Unified thresholds (preferred) diff --git a/apps/web/src/lib/curriculum/config/reinforcement-config.ts b/apps/web/src/lib/curriculum/config/reinforcement-config.ts deleted file mode 100644 index 09696865..00000000 --- a/apps/web/src/lib/curriculum/config/reinforcement-config.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Reinforcement System Configuration - * - * When a student struggles with a skill (needs heavy help or makes errors), - * the skill is flagged for reinforcement. This config controls: - * - What triggers reinforcement - * - How reinforcement is cleared - * - How help levels affect mastery credit - */ -export const REINFORCEMENT_CONFIG = { - /** - * Help level threshold that triggers reinforcement flag. - * Level 2+ (decomposition or bead arrows) indicates significant help needed. - */ - helpLevelThreshold: 2, - - /** - * Number of consecutive correct answers without heavy help to clear reinforcement. - */ - streakToClear: 3, - - /** - * Maximum help level that still counts toward clearing reinforcement. - * Level 1 (hints) is OK, but Level 2+ resets the streak. - */ - maxHelpLevelToCount: 1, - - /** - * Mastery credit multipliers based on help level. - * Used when updating skill mastery after a correct answer. - * - * - 0 (no help): 1.0 = full credit - * - 1 (hint): 1.0 = full credit (hints don't reduce credit) - * - 2 (decomposition): 0.5 = half credit - * - 3 (bead arrows): 0.25 = quarter credit - */ - creditMultipliers: { - 0: 1.0, - 1: 1.0, - 2: 0.5, - 3: 0.25, - } as Record<0 | 1 | 2 | 3, number>, -} as const - -export type ReinforcementConfig = typeof REINFORCEMENT_CONFIG diff --git a/apps/web/src/lib/curriculum/progress-manager.ts b/apps/web/src/lib/curriculum/progress-manager.ts index 5551868b..6897f9e9 100644 --- a/apps/web/src/lib/curriculum/progress-manager.ts +++ b/apps/web/src/lib/curriculum/progress-manager.ts @@ -7,7 +7,6 @@ import { and, desc, eq, inArray } from 'drizzle-orm' import { db, schema } from '@/db' import type { NewPlayerCurriculum, PlayerCurriculum } from '@/db/schema/player-curriculum' import type { NewPlayerSkillMastery, PlayerSkillMastery } from '@/db/schema/player-skill-mastery' -import { REINFORCEMENT_CONFIG } from '@/lib/curriculum/config' import type { PracticeSession } from '@/db/schema/practice-sessions' import type { HelpLevel } from '@/db/schema/session-plans' import { @@ -264,58 +263,30 @@ export async function recordSkillAttempt( /** * Record a skill attempt with help level tracking * - * Reinforcement logic: - * - If help level >= threshold, mark skill as needing reinforcement - * - If correct answer without heavy help, increment reinforcement streak - * - After N consecutive correct answers, clear reinforcement flag + * Updates the lastPracticedAt timestamp and tracks whether help was used. + * BKT handles mastery estimation via evidence weighting (helped answers get 0.5x weight). * - * NOTE: Attempt/correct statistics and response times are now computed on-the-fly - * from session results. This function only updates metadata and reinforcement tracking. + * NOTE: The old reinforcement system (based on help levels 2+) has been removed. + * Only boolean help (0 or 1) is recorded. BKT's conjunctive blame attribution + * identifies weak skills from multi-skill problems. */ export async function recordSkillAttemptWithHelp( playerId: string, skillId: string, - isCorrect: boolean, + _isCorrect: boolean, helpLevel: HelpLevel, _responseTimeMs?: number ): Promise { const existing = await getSkillMastery(playerId, skillId) const now = new Date() - // Determine if this help level triggers reinforcement tracking - const isHeavyHelp = helpLevel >= REINFORCEMENT_CONFIG.helpLevelThreshold - if (existing) { - // Reinforcement tracking - let needsReinforcement = existing.needsReinforcement - let reinforcementStreak = existing.reinforcementStreak - - if (isHeavyHelp) { - // Heavy help triggers reinforcement flag - needsReinforcement = true - reinforcementStreak = 0 - } else if (isCorrect && existing.needsReinforcement) { - // Correct answer without heavy help - increment streak toward clearing - reinforcementStreak = existing.reinforcementStreak + 1 - - // Clear reinforcement if streak reaches threshold - if (reinforcementStreak >= REINFORCEMENT_CONFIG.streakToClear) { - needsReinforcement = false - reinforcementStreak = 0 - } - } else if (!isCorrect) { - // Incorrect answer resets reinforcement streak - reinforcementStreak = 0 - } - await db .update(schema.playerSkillMastery) .set({ lastPracticedAt: now, updatedAt: now, - needsReinforcement, lastHelpLevel: helpLevel, - reinforcementStreak, }) .where(eq(schema.playerSkillMastery.id, existing.id)) @@ -328,9 +299,7 @@ export async function recordSkillAttemptWithHelp( skillId, isPracticing: true, // skill is being practiced lastPracticedAt: now, - needsReinforcement: isHeavyHelp, lastHelpLevel: helpLevel, - reinforcementStreak: 0, } await db.insert(schema.playerSkillMastery).values(newRecord) @@ -364,54 +333,6 @@ export async function recordSkillAttemptsWithHelp( return results } -/** - * Get skills that need reinforcement for a player - */ -export async function getSkillsNeedingReinforcement( - playerId: string -): Promise { - return db.query.playerSkillMastery.findMany({ - where: and( - eq(schema.playerSkillMastery.playerId, playerId), - eq(schema.playerSkillMastery.needsReinforcement, true) - ), - orderBy: desc(schema.playerSkillMastery.lastPracticedAt), - }) -} - -/** - * Clear reinforcement for a specific skill (teacher override) - */ -export async function clearSkillReinforcement(playerId: string, skillId: string): Promise { - await db - .update(schema.playerSkillMastery) - .set({ - needsReinforcement: false, - reinforcementStreak: 0, - updatedAt: new Date(), - }) - .where( - and( - eq(schema.playerSkillMastery.playerId, playerId), - eq(schema.playerSkillMastery.skillId, skillId) - ) - ) -} - -/** - * Clear all reinforcement flags for a player (teacher override) - */ -export async function clearAllReinforcement(playerId: string): Promise { - await db - .update(schema.playerSkillMastery) - .set({ - needsReinforcement: false, - reinforcementStreak: 0, - updatedAt: new Date(), - }) - .where(eq(schema.playerSkillMastery.playerId, playerId)) -} - /** * Record multiple skill attempts at once (for batch updates after a problem) */ @@ -613,8 +534,6 @@ export interface SkillPerformanceAnalysis { slowSkills: SkillPerformance[] /** Skills with low accuracy that may need intervention */ lowAccuracySkills: SkillPerformance[] - /** Skills needing reinforcement (from help system) */ - reinforcementSkills: SkillPerformance[] } /** @@ -729,18 +648,12 @@ export async function analyzeSkillPerformance(playerId: string): Promise r.skillId)) - const reinforcementSkills = skills.filter((s) => reinforcementSkillIds.has(s.skillId)) - return { skills, overallAvgResponseTimeMs, fastSkills, slowSkills, lowAccuracySkills, - reinforcementSkills, } } diff --git a/apps/web/src/test/journey-simulator/SimulatedStudent.test.ts b/apps/web/src/test/journey-simulator/SimulatedStudent.test.ts index 622ed85f..fe2c7105 100644 --- a/apps/web/src/test/journey-simulator/SimulatedStudent.test.ts +++ b/apps/web/src/test/journey-simulator/SimulatedStudent.test.ts @@ -24,8 +24,8 @@ function createTestProfile(overrides: Partial = {}): StudentProf halfMaxExposure: 10, // K = 10: 50% at 10 exposures hillCoefficient: 2, // n = 2: xยฒ curve shape initialExposures: {}, - helpUsageProbabilities: [1.0, 0, 0, 0], // Always no help for deterministic tests - helpBonuses: [0, 0.1, 0.2, 0.3], + helpUsageProbabilities: [1.0, 0], // Always no help for deterministic tests + helpBonuses: [0, 0], baseResponseTimeMs: 5000, responseTimeVariance: 0, ...overrides, @@ -384,8 +384,8 @@ describe('SimulatedStudent Hill Function', () => { it('should clamp probability to valid range', () => { // Even with 0 skills and max help, probability shouldn't exceed 0.98 const profile = createTestProfile({ - helpUsageProbabilities: [0, 0, 0, 1.0], // Always max help - helpBonuses: [0, 0.1, 0.2, 0.5], // Large bonus + helpUsageProbabilities: [0, 1.0], // Always uses help + helpBonuses: [0, 0.5], // Large bonus }) const student = new SimulatedStudent(profile, new SeededRandom(12345)) diff --git a/apps/web/src/test/journey-simulator/SimulatedStudent.ts b/apps/web/src/test/journey-simulator/SimulatedStudent.ts index 123cbe19..cb1f05ce 100644 --- a/apps/web/src/test/journey-simulator/SimulatedStudent.ts +++ b/apps/web/src/test/journey-simulator/SimulatedStudent.ts @@ -174,21 +174,21 @@ export class SimulatedStudent { this.skillExposures.set(skillId, current + 1) } - // Determine help level (probabilistic based on profile) - const helpLevel = this.selectHelpLevel() + // Determine if student uses help (binary) + const helpLevelUsed = this.selectHelpLevel() // Calculate answer probability using Hill function + conjunctive model - const answerProbability = this.calculateAnswerProbability(skillsChallenged, helpLevel) + const answerProbability = this.calculateAnswerProbability(skillsChallenged, helpLevelUsed) const isCorrect = this.rng.chance(answerProbability) // Calculate response time - const responseTimeMs = this.calculateResponseTime(skillsChallenged, helpLevel, isCorrect) + const responseTimeMs = this.calculateResponseTime(skillsChallenged, helpLevelUsed, isCorrect) return { isCorrect, responseTimeMs, - helpLevelUsed: helpLevel, + helpLevelUsed, skillsChallenged, fatigue, } @@ -200,7 +200,7 @@ export class SimulatedStudent { * For multi-skill problems, each skill must be applied correctly: * P(correct) = P(skill_A) ร— P(skill_B) ร— P(skill_C) ร— ... * - * Help bonuses are additive (applied after the product). + * Help bonus is additive (applied after the product). */ private calculateAnswerProbability(skillIds: string[], helpLevel: HelpLevel): number { if (skillIds.length === 0) { @@ -229,17 +229,12 @@ export class SimulatedStudent { } /** - * Select a help level based on the student's profile probabilities. + * Select whether student uses help (binary). + * Based on profile's helpUsageProbabilities [P(no help), P(help)]. */ private selectHelpLevel(): HelpLevel { - const [p0, p1, p2, p3] = this.profile.helpUsageProbabilities - const roll = this.rng.next() - - // Cumulative probability check - if (roll < p0) return 0 - if (roll < p0 + p1) return 1 - if (roll < p0 + p1 + p2) return 2 - return 3 + const [pNoHelp] = this.profile.helpUsageProbabilities + return this.rng.next() < pNoHelp ? 0 : 1 } /** diff --git a/apps/web/src/test/journey-simulator/profiles/average-learner.ts b/apps/web/src/test/journey-simulator/profiles/average-learner.ts index 83098106..f68f5ccb 100644 --- a/apps/web/src/test/journey-simulator/profiles/average-learner.ts +++ b/apps/web/src/test/journey-simulator/profiles/average-learner.ts @@ -64,11 +64,11 @@ export const averageLearnerProfile: StudentProfile = { initialExposures, - // Moderate help usage: 55% no help, 25% hint, 15% decomp, 5% full - helpUsageProbabilities: [0.55, 0.25, 0.15, 0.05], + // Moderate help usage: 55% no help, 45% uses help + helpUsageProbabilities: [0.55, 0.45], - // Standard help bonuses - helpBonuses: [0, 0.06, 0.15, 0.3], + // Help bonus: 15% additive when help is used + helpBonuses: [0, 0.15], // Average response time baseResponseTimeMs: 5500, diff --git a/apps/web/src/test/journey-simulator/profiles/fast-learner.ts b/apps/web/src/test/journey-simulator/profiles/fast-learner.ts index 6060bead..50fd4873 100644 --- a/apps/web/src/test/journey-simulator/profiles/fast-learner.ts +++ b/apps/web/src/test/journey-simulator/profiles/fast-learner.ts @@ -63,11 +63,11 @@ export const fastLearnerProfile: StudentProfile = { initialExposures, - // Rarely needs help: 70% no help, 20% hint, 8% decomp, 2% full - helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02], + // Rarely needs help: 70% no help, 30% uses help + helpUsageProbabilities: [0.7, 0.3], - // Help bonuses (additive to probability) - helpBonuses: [0, 0.05, 0.12, 0.25], + // Help bonus: 12% additive when help is used + helpBonuses: [0, 0.12], // Relatively fast responses baseResponseTimeMs: 4000, diff --git a/apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts b/apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts index 1c57e085..56a2be7f 100644 --- a/apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts +++ b/apps/web/src/test/journey-simulator/profiles/per-skill-deficiency.ts @@ -90,8 +90,8 @@ export const LEARNER_TYPES = { * - 75 exposures โ†’ 90% (mastered) */ masteredExposure: 75, - helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02] as [number, number, number, number], - helpBonuses: [0, 0.05, 0.12, 0.25] as [number, number, number, number], + helpUsageProbabilities: [0.7, 0.3] as [number, number], + helpBonuses: [0, 0.12] as [number, number], baseResponseTimeMs: 4000, responseTimeVariance: 0.25, }, @@ -108,8 +108,8 @@ export const LEARNER_TYPES = { * - 120 exposures โ†’ 94% (mastered) */ masteredExposure: 120, - helpUsageProbabilities: [0.55, 0.25, 0.15, 0.05] as [number, number, number, number], - helpBonuses: [0, 0.06, 0.15, 0.3] as [number, number, number, number], + helpUsageProbabilities: [0.55, 0.45] as [number, number], + helpBonuses: [0, 0.15] as [number, number], baseResponseTimeMs: 5500, responseTimeVariance: 0.35, }, @@ -126,8 +126,8 @@ export const LEARNER_TYPES = { * - 150 exposures โ†’ 94% (mastered) */ masteredExposure: 150, - helpUsageProbabilities: [0.4, 0.3, 0.2, 0.1] as [number, number, number, number], - helpBonuses: [0, 0.08, 0.18, 0.35] as [number, number, number, number], + helpUsageProbabilities: [0.4, 0.6] as [number, number], + helpBonuses: [0, 0.2] as [number, number], baseResponseTimeMs: 7000, responseTimeVariance: 0.4, }, diff --git a/apps/web/src/test/journey-simulator/profiles/slow-learner.ts b/apps/web/src/test/journey-simulator/profiles/slow-learner.ts index 835d60ad..0f15fc1f 100644 --- a/apps/web/src/test/journey-simulator/profiles/slow-learner.ts +++ b/apps/web/src/test/journey-simulator/profiles/slow-learner.ts @@ -69,11 +69,11 @@ export const slowLearnerProfile: StudentProfile = { initialExposures, - // Uses help often: 40% no help, 30% hint, 20% decomp, 10% full - helpUsageProbabilities: [0.4, 0.3, 0.2, 0.1], + // Uses help often: 40% no help, 60% uses help + helpUsageProbabilities: [0.4, 0.6], - // Higher help bonuses (helps more when used) - helpBonuses: [0, 0.08, 0.18, 0.35], + // Help bonus: 20% additive when help is used + helpBonuses: [0, 0.2], // Takes longer to respond baseResponseTimeMs: 8000, diff --git a/apps/web/src/test/journey-simulator/profiles/stark-contrast.ts b/apps/web/src/test/journey-simulator/profiles/stark-contrast.ts index 73f21e38..d05e3bbe 100644 --- a/apps/web/src/test/journey-simulator/profiles/stark-contrast.ts +++ b/apps/web/src/test/journey-simulator/profiles/stark-contrast.ts @@ -55,11 +55,11 @@ export const starkContrastProfile: StudentProfile = { initialExposures, // Uses less help to make weakness more apparent - // 70% no help, 20% hint, 8% decomp, 2% full - helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02], + // 70% no help, 30% uses help + helpUsageProbabilities: [0.7, 0.3], - // Lower help bonuses (weakness is more visible) - helpBonuses: [0, 0.05, 0.12, 0.25], + // Help bonus: 12% additive when help is used + helpBonuses: [0, 0.12], // Average response time baseResponseTimeMs: 5000, diff --git a/apps/web/src/test/journey-simulator/profiles/uneven-skills.ts b/apps/web/src/test/journey-simulator/profiles/uneven-skills.ts index 2ad77ddb..458b174b 100644 --- a/apps/web/src/test/journey-simulator/profiles/uneven-skills.ts +++ b/apps/web/src/test/journey-simulator/profiles/uneven-skills.ts @@ -58,11 +58,11 @@ export const unevenSkillsProfile: StudentProfile = { initialExposures, - // Moderate help usage: 55% no help, 25% hint, 15% decomp, 5% full - helpUsageProbabilities: [0.55, 0.25, 0.15, 0.05], + // Moderate help usage: 55% no help, 45% uses help + helpUsageProbabilities: [0.55, 0.45], - // Standard help bonuses - helpBonuses: [0, 0.06, 0.15, 0.3], + // Help bonus: 15% additive when help is used + helpBonuses: [0, 0.15], // Average response time baseResponseTimeMs: 5500, diff --git a/apps/web/src/test/journey-simulator/skill-difficulty.test.ts b/apps/web/src/test/journey-simulator/skill-difficulty.test.ts index 3851dd4a..b30f2113 100644 --- a/apps/web/src/test/journey-simulator/skill-difficulty.test.ts +++ b/apps/web/src/test/journey-simulator/skill-difficulty.test.ts @@ -11,13 +11,13 @@ import * as schema from '@/db/schema' import { createEphemeralDatabase, createTestStudent, + type EphemeralDbResult, getCurrentEphemeralDb, setCurrentEphemeralDb, - type EphemeralDbResult, } from './EphemeralDatabase' import { JourneyRunner } from './JourneyRunner' import { SeededRandom } from './SeededRandom' -import { SimulatedStudent, getTrueMultiplier } from './SimulatedStudent' +import { getTrueMultiplier, SimulatedStudent } from './SimulatedStudent' import type { JourneyConfig, JourneyResult, StudentProfile } from './types' // Mock the @/db module to use our ephemeral database @@ -39,8 +39,8 @@ const STANDARD_PROFILE: StudentProfile = { halfMaxExposure: 10, // Base K=10, multiplied by skill difficulty hillCoefficient: 2.0, // Standard curve shape initialExposures: {}, // Start from scratch - helpUsageProbabilities: [1.0, 0, 0, 0], // No help for clean measurements - helpBonuses: [0, 0, 0, 0], + helpUsageProbabilities: [1.0, 0], // No help for clean measurements + helpBonuses: [0, 0], baseResponseTimeMs: 5000, responseTimeVariance: 0.3, } @@ -460,8 +460,8 @@ describe('A/B Mastery Trajectories', () => { .filter((s) => s !== deficientSkillId) .map((s) => [s, 25]) // 25 exposures = ~86% mastery for basic, ~73% for five-comp ), - helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02], - helpBonuses: [0, 0.05, 0.12, 0.25], + helpUsageProbabilities: [0.7, 0.3], // 70% no help, 30% uses help + helpBonuses: [0, 0.25], // Help bonus when used baseResponseTimeMs: 5000, responseTimeVariance: 0.3, }) diff --git a/apps/web/src/test/journey-simulator/types.ts b/apps/web/src/test/journey-simulator/types.ts index 8e0c909a..1bddd795 100644 --- a/apps/web/src/test/journey-simulator/types.ts +++ b/apps/web/src/test/journey-simulator/types.ts @@ -55,17 +55,17 @@ export interface StudentProfile { initialExposures: Record /** - * Probability distribution for help level usage [none, hint, decomp, highlight] + * Probability of using help: [P(no help), P(help)] * Must sum to 1.0 + * Example: [0.7, 0.3] means 70% no help, 30% uses help */ - helpUsageProbabilities: [number, number, number, number] + helpUsageProbabilities: [number, number] /** - * Additive bonus to P(correct) per help level [none, hint, decomp, highlight] - * These are additive to the base probability, not multiplicative. - * Example: [0, 0.05, 0.12, 0.25] means full help adds 25% to success chance + * Additive bonus to P(correct) when help is used: [no help bonus, help bonus] + * Example: [0, 0.15] means using help adds 15% to success chance */ - helpBonuses: [number, number, number, number] + helpBonuses: [number, number] /** Base response time in milliseconds */ baseResponseTimeMs: number @@ -114,7 +114,7 @@ export interface SimulatedAnswer { isCorrect: boolean /** Time taken to answer in milliseconds */ responseTimeMs: number - /** Help level used (0 = none, 3 = full) */ + /** Help level used (0 = no help, 1 = used help) */ helpLevelUsed: HelpLevel /** Skills that were actually challenged by this problem */ skillsChallenged: string[]