refactor(help): simplify to binary help system + add seed script CLI

Help System Cleanup:
- Simplify HelpLevel from 0|1|2|3 to binary 0|1 (matching actual usage)
- Change BKT help weight from 0.8 to 0.5 for helped answers
- Delete unused PracticeHelpPanel.tsx (~540 lines)
- Delete unused usePracticeHelp.ts (~400 lines)
- Delete unused reinforcement-config.ts
- Remove reinforcement tracking (BKT handles skill weakness detection)
- Update all journey simulator profiles to use binary help arrays
- Update documentation (BKT_DESIGN_SPEC.md, decomposition README)

Seed Script Improvements:
- Add CLI argument parsing with node:util parseArgs
- Add --help, --list, --name, --category, --dry-run flags
- Add new "Forgotten Weaknesses" test profile (weak + stale skill mix)
- Enable seeding individual students or categories

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-21 06:28:06 -06:00
parent b9d4bc552a
commit 446678799c
27 changed files with 334 additions and 1436 deletions

View File

@ -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": []

View File

@ -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
}
/**

View File

@ -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 <name> Seed specific student(s) by name (can use multiple times)
--category, -c <cat> 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<ProfileCategory, TestStudentProfile[]> = {
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`)

View File

@ -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 (
<DecompositionProvider

View File

@ -1,540 +0,0 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import type { HelpLevel } from '@/db/schema/session-plans'
import type { PracticeHelpState } from '@/hooks/usePracticeHelp'
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
import { css } from '../../../styled-system/css'
import { DecompositionDisplay, DecompositionProvider } from '../decomposition'
import { HelpAbacus } from './HelpAbacus'
/**
* Generate a dynamic coach hint based on the current step
*/
function generateDynamicCoachHint(
sequence: ReturnType<typeof generateUnifiedInstructionSequence> | 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<HelpLevel, string> = {
0: 'No Help',
1: 'Hint',
2: 'Show Steps',
3: 'Show Beads',
}
/**
* Help level icons
*/
const HELP_LEVEL_ICONS: Record<HelpLevel, string> = {
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 (
<div
data-component="practice-help-panel"
data-level={effectiveLevel}
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.75rem',
padding: '1rem',
backgroundColor: isDark ? 'blue.900' : 'blue.50',
borderRadius: '12px',
border: '2px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
})}
>
{/* Header with level indicator */}
<div
data-element="help-header"
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span className={css({ fontSize: '1.25rem' })}>{HELP_LEVEL_ICONS[effectiveLevel]}</span>
<span
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'blue.200' : 'blue.700',
})}
>
{HELP_LEVEL_LABELS[effectiveLevel]}
</span>
{/* Help level indicator dots */}
<div
className={css({
display: 'flex',
gap: '0.25rem',
marginLeft: '0.5rem',
})}
>
{[1, 2, 3].map((level) => (
<div
key={level}
className={css({
width: '8px',
height: '8px',
borderRadius: '50%',
backgroundColor:
level <= effectiveLevel ? 'blue.500' : isDark ? 'blue.700' : 'blue.200',
})}
/>
))}
</div>
</div>
<button
type="button"
data-action="dismiss-help"
onClick={handleDismiss}
className={css({
padding: '0.25rem 0.5rem',
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
backgroundColor: 'transparent',
border: 'none',
cursor: 'pointer',
_hover: {
color: isDark ? 'gray.200' : 'gray.700',
},
})}
>
Hide
</button>
</div>
{/* Level 1: Coach hint - uses dynamic hint that updates with abacus progress */}
{effectiveLevel >= 1 && dynamicCoachHint && (
<div
data-element="coach-hint"
data-step-index={currentStepIndex}
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<p
className={css({
fontSize: '1rem',
color: isDark ? 'gray.300' : 'gray.700',
lineHeight: '1.5',
})}
>
{dynamicCoachHint}
</p>
</div>
)}
{/* Level 2: Decomposition */}
{effectiveLevel >= 2 &&
content?.decomposition &&
content.decomposition.isMeaningful &&
currentValue !== undefined &&
targetValue !== undefined && (
<div
data-element="decomposition-container"
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: isDark ? 'blue.300' : 'blue.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
})}
>
Step-by-Step
</div>
<div
data-element="decomposition-display"
className={css({
fontFamily: 'monospace',
fontSize: '1.125rem',
color: isDark ? 'gray.100' : 'gray.800',
wordBreak: 'break-word',
})}
>
<DecompositionProvider
startValue={currentValue}
targetValue={targetValue}
currentStepIndex={currentStepIndex}
abacusColumns={3}
>
<DecompositionDisplay />
</DecompositionProvider>
</div>
</div>
)}
{/* Level 3: Visual abacus with bead arrows */}
{effectiveLevel >= 3 && currentValue !== undefined && targetValue !== undefined && (
<div
data-element="help-abacus"
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'purple.700' : 'purple.200',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: isDark ? 'purple.300' : 'purple.600',
marginBottom: '0.75rem',
textTransform: 'uppercase',
textAlign: 'center',
})}
>
🧮 Follow the Arrows
</div>
<HelpAbacus
currentValue={currentValue}
targetValue={targetValue}
columns={3}
scaleFactor={1.0}
interactive={true}
onValueChange={handleAbacusValueChange}
/>
{isAbacusPart && (
<div
className={css({
marginTop: '0.75rem',
padding: '0.5rem',
backgroundColor: isDark ? 'purple.900' : 'purple.50',
borderRadius: '6px',
fontSize: '0.75rem',
color: isDark ? 'purple.200' : 'purple.700',
textAlign: 'center',
})}
>
Try following these movements on your physical abacus
</div>
)}
</div>
)}
{/* Fallback: Text bead steps if abacus values not provided */}
{effectiveLevel >= 3 &&
(currentValue === undefined || targetValue === undefined) &&
content?.beadSteps &&
content.beadSteps.length > 0 && (
<div
data-element="bead-steps-text"
className={css({
padding: '0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: isDark ? 'blue.800' : 'blue.100',
})}
>
<div
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: isDark ? 'purple.300' : 'purple.600',
marginBottom: '0.5rem',
textTransform: 'uppercase',
})}
>
Bead Movements
</div>
<ol
className={css({
listStyle: 'decimal',
paddingLeft: '1.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{content.beadSteps.map((step, index) => (
<li
key={index}
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.300' : 'gray.700',
})}
>
<span
className={css({
fontWeight: 'bold',
color: isDark ? 'purple.300' : 'purple.700',
})}
>
{step.mathematicalTerm}
</span>
{step.englishInstruction && (
<span
className={css({
color: isDark ? 'gray.400' : 'gray.600',
})}
>
{' '}
{step.englishInstruction}
</span>
)}
</li>
))}
</ol>
</div>
)}
{/* More help button (if not at max level) */}
{effectiveLevel < 3 && (
<button
type="button"
data-action="escalate-help"
onClick={handleRequestHelp}
className={css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
width: '100%',
padding: '0.5rem',
fontSize: '0.875rem',
color: isDark ? 'blue.300' : 'blue.600',
backgroundColor: isDark ? 'gray.800' : 'white',
border: '1px solid',
borderColor: isDark ? 'blue.700' : 'blue.200',
borderRadius: '6px',
cursor: 'pointer',
transition: 'all 0.2s ease',
_hover: {
backgroundColor: isDark ? 'gray.700' : 'blue.50',
},
})}
>
<span>{HELP_LEVEL_ICONS[(effectiveLevel + 1) as HelpLevel]}</span>
<span>More Help: {HELP_LEVEL_LABELS[(effectiveLevel + 1) as HelpLevel]}</span>
</button>
)}
{/* Max level indicator */}
{maxLevelUsed > 0 && (
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.400',
textAlign: 'center',
})}
>
Help used: Level {maxLevelUsed}
</div>
)}
</div>
)
}
export default PracticeHelpPanel

View File

@ -277,47 +277,6 @@ export const DifferentStudents: Story = {
),
}
/**
* With Focus Areas - showing skills needing reinforcement
*/
export const WithFocusAreas: Story = {
render: () => (
<DashboardWrapper>
<ProgressDashboard
student={sampleStudent}
currentPhase={intermediatePhase}
focusAreas={[
{
skillId: 'fiveComplements.3=5-2',
skillName: '+3 Five Complement',
bktClassification: 'developing',
attempts: 15,
correct: 10,
consecutiveCorrect: 1,
needsReinforcement: true,
lastHelpLevel: 2,
reinforcementStreak: 1,
},
{
skillId: 'tenComplements.8=10-2',
skillName: '+8 Ten Complement',
bktClassification: 'weak',
attempts: 8,
correct: 4,
consecutiveCorrect: 0,
needsReinforcement: true,
lastHelpLevel: 3,
reinforcementStreak: 0,
},
]}
onClearReinforcement={(skillId) => alert(`Clear reinforcement for ${skillId}`)}
onClearAllReinforcement={() => alert('Clear all reinforcement')}
{...handlers}
/>
</DashboardWrapper>
),
}
// Note: Active session resume/start functionality has been moved to the
// SessionModeBanner system (see ActiveSessionBanner.tsx and ProjectingBanner.tsx)

View File

@ -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({
</p>
)}
</div>
{/* Focus Areas - Skills needing extra practice */}
{focusAreas.length > 0 && (
<div
data-section="focus-areas"
className={css({
width: '100%',
padding: '1rem',
backgroundColor: isDark ? 'orange.900' : 'orange.50',
borderRadius: '12px',
border: '1px solid',
borderColor: isDark ? 'orange.700' : 'orange.200',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '0.75rem',
})}
>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
color: isDark ? 'orange.200' : 'orange.700',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span>🎯</span>
Focus Areas
</h3>
{onClearAllReinforcement && focusAreas.length > 1 && (
<button
type="button"
data-action="clear-all-reinforcement"
onClick={onClearAllReinforcement}
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.500',
background: 'none',
border: 'none',
cursor: 'pointer',
_hover: {
color: isDark ? 'gray.200' : 'gray.700',
textDecoration: 'underline',
},
})}
>
Clear All
</button>
)}
</div>
<p
className={css({
fontSize: '0.75rem',
color: isDark ? 'orange.300' : 'orange.600',
marginBottom: '0.75rem',
})}
>
These skills need extra practice:
</p>
<div
className={css({
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{focusAreas.map((skill) => (
<div
key={skill.skillId}
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '0.5rem 0.75rem',
backgroundColor: isDark ? 'gray.800' : 'white',
borderRadius: '6px',
border: '1px solid',
borderColor: isDark ? 'orange.800' : 'orange.100',
})}
>
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.200' : 'gray.700',
fontWeight: 'medium',
})}
>
{skill.skillName}
</span>
{skill.reinforcementStreak !== undefined && skill.reinforcementStreak > 0 && (
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'green.400' : 'green.600',
})}
title={`${skill.reinforcementStreak} correct answers toward clearing`}
>
({skill.reinforcementStreak}/3)
</span>
)}
</div>
{onClearReinforcement && (
<button
type="button"
data-action="clear-reinforcement"
onClick={() => onClearReinforcement(skill.skillId)}
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.400',
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '0.25rem',
_hover: {
color: isDark ? 'gray.300' : 'gray.600',
},
})}
title="Mark as mastered (teacher only)"
>
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
)
}

View File

@ -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) && (
<div className={css({ marginBottom: '20px' })}>
<h4
className={css({
@ -424,20 +422,6 @@ export function SkillPerformanceReports({
overallAvgMs={analysis.overallAvgResponseTimeMs}
/>
))}
{analysis.reinforcementSkills
.filter(
(s) =>
!analysis.slowSkills.some((slow) => slow.skillId === s.skillId) &&
!analysis.lowAccuracySkills.some((low) => low.skillId === s.skillId)
)
.map((skill) => (
<SkillCard
key={skill.skillId}
skill={skill}
isDark={isDark}
overallAvgMs={analysis.overallAvgResponseTimeMs}
/>
))}
</div>
</div>
)}

View File

@ -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')
}

View File

@ -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 */

View File

@ -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,
}

View File

@ -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

View File

@ -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<TermContext | null>(null)
// Help state
const [currentLevel, setCurrentLevel] = useState<HelpLevel>(0)
const [maxLevelUsed, setMaxLevelUsed] = useState<HelpLevel>(0)
const [trigger, setTrigger] = useState<PracticeHelpState['trigger']>('none')
const [errorCount, setErrorCount] = useState(0)
// Timer for auto-escalation
const [startTime, setStartTime] = useState<number | null>(null)
const [elapsedTimeMs, setElapsedTimeMs] = useState(0)
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null)
// Generate instruction sequence for current term
const sequence = useMemo<UnifiedInstructionSequence | null>(() => {
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<ExtractedSkill[]>(() => {
if (!sequence) return []
return extractSkillsFromSequence(sequence)
}, [sequence])
// Build help content
const content = useMemo<HelpContent | null>(() => {
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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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<PlayerSkillMastery> {
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<PlayerSkillMastery[]> {
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<void> {
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<void> {
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<SkillPe
s.accuracy < PERFORMANCE_THRESHOLDS.minAccuracyThreshold
)
// Get skills needing reinforcement
const reinforcementRecords = await getSkillsNeedingReinforcement(playerId)
const reinforcementSkillIds = new Set(reinforcementRecords.map((r) => r.skillId))
const reinforcementSkills = skills.filter((s) => reinforcementSkillIds.has(s.skillId))
return {
skills,
overallAvgResponseTimeMs,
fastSkills,
slowSkills,
lowAccuracySkills,
reinforcementSkills,
}
}

View File

@ -24,8 +24,8 @@ function createTestProfile(overrides: Partial<StudentProfile> = {}): 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))

View File

@ -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
}
/**

View File

@ -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,

View File

@ -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,

View File

@ -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,
},

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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,
})

View File

@ -55,17 +55,17 @@ export interface StudentProfile {
initialExposures: Record<string, number>
/**
* 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[]