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) => (
- -
-
- {step.mathematicalTerm}
-
- {step.englishInstruction && (
-
- {' '}
- โ {step.englishInstruction}
-
- )}
-
- ))}
-
-
- )}
-
- {/* 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[]