fix(practice): correct five complement skill detection for addition and subtraction

Fix bugs where five complement skills were incorrectly detected when the
heaven bead was already active:

Addition: When adding 1-4 results in 6-9 and currentDigit >= 5, no five
complement is needed - just add earth beads directly.

Subtraction: When ten complement addition crosses 5 boundary but
currentDigit >= 5, no five complement is needed for the addition part.

Also includes UX improvements from previous session:
- Dream sequence: show target value for 1s then fade out after help completion
- Clear answer boxes on help abacus dismiss
- Handle typing during help mode

Add comprehensive unit tests for column analysis functions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-11 06:50:57 -06:00
parent bcb1c7a173
commit 1139c4d1a1
7 changed files with 296 additions and 35 deletions

View File

@ -82,8 +82,8 @@ export function PracticeClient({ studentId, player, initialSession }: PracticeCl
<main
data-component="practice-page"
className={css({
minHeight: '100vh',
paddingTop: '80px', // Nav height only, no extra padding for practice
minHeight: 'calc(100vh - 80px)', // Full height minus nav
marginTop: '80px', // Push below nav
})}
>
<PracticeErrorBoundary studentName={player.name}>

View File

@ -237,6 +237,7 @@ export function ActiveSession({
enterHelpMode,
exitHelpMode,
clearAnswer,
setAnswer,
startSubmit,
completeSubmit,
startTransition,
@ -253,12 +254,15 @@ export function ActiveSession({
// These reset when entering a new help session (helpContext changes)
const [helpAbacusDismissed, setHelpAbacusDismissed] = useState(false)
const [helpPanelDismissed, setHelpPanelDismissed] = useState(false)
// Track when answer is fading out (for dream sequence)
const [answerFadingOut, setAnswerFadingOut] = useState(false)
// Reset dismissed states when help context changes (new help session)
useEffect(() => {
if (helpContext) {
setHelpAbacusDismissed(false)
setHelpPanelDismissed(false)
setAnswerFadingOut(false)
}
}, [helpContext])
@ -390,12 +394,32 @@ export function ActiveSession({
}, [phase, matchedPrefixIndex, prefixSums.length, enterHelpMode])
// Handle when student reaches target value on help abacus
// Sequence: show target value → dismiss abacus → show value in answer boxes → fade to empty → exit
const handleTargetReached = useCallback(() => {
if (phase.phase !== 'helpMode') return
if (phase.phase !== 'helpMode' || !helpContext) return
// Step 1: Set the answer to the target value (shows in answer boxes behind abacus)
setAnswer(String(helpContext.targetValue))
// Step 2: After a brief moment, dismiss the help abacus to reveal the answer
setTimeout(() => {
exitHelpMode()
}, 800)
}, [phase.phase, exitHelpMode])
setHelpAbacusDismissed(true)
// Step 3: After abacus fades out (300ms), answer boxes are visible with target value
// Wait 1 second to let user see the result
setTimeout(() => {
// Step 4: Start fading out the answer
setAnswerFadingOut(true)
// Step 5: After fade-out completes (300ms), clear and exit
setTimeout(() => {
clearAnswer()
setAnswerFadingOut(false)
exitHelpMode()
}, 300) // Match fade-out duration
}, 1000) // Show target value in answer boxes for 1 second
}, 600) // Brief pause to see success state on abacus
}, [phase.phase, helpContext, setAnswer, clearAnswer, exitHelpMode])
// Handle submit
const handleSubmit = useCallback(async () => {
@ -971,6 +995,7 @@ export function ActiveSession({
helpOverlayVisible={showHelpOverlay && !helpAbacusDismissed}
helpOverlayTransitionMs={helpAbacusDismissed ? 300 : 1000}
onHelpOverlayTransitionEnd={clearAnswer}
answerFadingOut={answerFadingOut}
generationTrace={attempt.problem.generationTrace}
complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm}
/>

View File

@ -135,7 +135,7 @@ export function PlanReview({ plan, studentName, onApprove, onCancel }: PlanRevie
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
{/* Time and problem count */}
{/* Time and focus */}
<div
className={css({
display: 'flex',
@ -157,14 +157,6 @@ export function PlanReview({ plan, studentName, onApprove, onCancel }: PlanRevie
>
~{summary.estimatedMinutes} min
</div>
<div
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
{summary.totalProblemCount} problems
</div>
</div>
<div
className={css({

View File

@ -34,6 +34,8 @@ interface VerticalProblemProps {
helpOverlayTransitionMs?: number
/** Called when help overlay transition completes (useful for clearing answer after fade-in) */
onHelpOverlayTransitionEnd?: () => void
/** Whether the answer row is fading out (for dream sequence after help mode) */
answerFadingOut?: boolean
/** Generation trace with per-term skills and complexity (for debug overlay) */
generationTrace?: GenerationTrace
/** Complexity budget constraint (for debug overlay) */
@ -63,6 +65,7 @@ export function VerticalProblem({
helpOverlayVisible = false,
helpOverlayTransitionMs = 1000,
onHelpOverlayTransitionEnd,
answerFadingOut = false,
generationTrace,
complexityBudget,
}: VerticalProblemProps) {
@ -372,7 +375,11 @@ export function VerticalProblem({
})}
onTransitionEnd={(e) => {
// Only fire for opacity transition on this element, and only when becoming visible
if (e.propertyName === 'opacity' && e.target === e.currentTarget && helpOverlayVisible) {
if (
e.propertyName === 'opacity' &&
e.target === e.currentTarget &&
helpOverlayVisible
) {
onHelpOverlayTransitionEnd?.()
}
}}
@ -384,6 +391,7 @@ export function VerticalProblem({
{/* Answer row layer - fades out when help mode active (inverse opacity) */}
<div
data-element="answer-row"
data-fading-out={answerFadingOut ? 'true' : undefined}
className={css({
display: 'flex',
alignItems: 'center',
@ -392,9 +400,11 @@ export function VerticalProblem({
top: helpOverlay ? 0 : undefined,
left: helpOverlay ? 0 : undefined,
right: helpOverlay ? 0 : undefined,
// Fade out when help mode active (inverse of help overlay)
transition: `opacity ${helpOverlayTransitionMs}ms ease-out`,
opacity: helpOverlayVisible ? 0 : 1,
// Fade out when help mode active (inverse of help overlay) OR when answer is fading out
transition: answerFadingOut
? 'opacity 300ms ease-out'
: `opacity ${helpOverlayTransitionMs}ms ease-out`,
opacity: helpOverlayVisible || answerFadingOut ? 0 : 1,
pointerEvents: helpOverlayVisible ? 'none' : 'auto',
})}
>

View File

@ -369,6 +369,8 @@ export interface UseInteractionPhaseReturn {
exitHelpMode: () => void
/** Clear the current answer (used after help overlay transition completes) */
clearAnswer: () => void
/** Set the answer to a specific value (used when showing target reached value) */
setAnswer: (value: string) => void
/** Submit answer (inputting/helpMode → submitting) */
startSubmit: () => void
/** Handle submit result (submitting → showingFeedback) */
@ -767,6 +769,17 @@ export function useInteractionPhase(
})
}, [])
const setAnswer = useCallback((value: string) => {
setPhase((prev) => {
if (prev.phase !== 'helpMode' && prev.phase !== 'inputting') return prev
const updatedAttempt = { ...prev.attempt, userAnswer: value }
if (prev.phase === 'helpMode') {
return { phase: 'helpMode', attempt: updatedAttempt, helpContext: prev.helpContext }
}
return { phase: 'inputting', attempt: updatedAttempt }
})
}, [])
const startSubmit = useCallback(() => {
setPhase((prev) => {
// Allow submitting from inputting, awaitingDisambiguation, or helpMode
@ -868,6 +881,7 @@ export function useInteractionPhase(
enterHelpMode,
exitHelpMode,
clearAnswer,
setAnswer,
startSubmit,
completeSubmit,
startTransition,

View File

@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest'
import { analyzeColumnAddition, analyzeColumnSubtraction } from '../problemGenerator'
// Helper to simplify calls - the function takes (currentDigit, termDigit, resultDigit, column)
// For these unit tests, resultDigit and column are not used in the logic we're testing
const testAddition = (currentDigit: number, termDigit: number) => {
const resultDigit = (currentDigit + termDigit) % 10
return analyzeColumnAddition(currentDigit, termDigit, resultDigit, 0)
}
describe('analyzeColumnAddition', () => {
describe('five complement detection', () => {
it('detects five complement when heaven bead NOT active and result crosses 5', () => {
// 3 + 4 = 7: heaven bead not active (3 < 5), result needs heaven bead
// Correct technique: +5, -1 (five complement)
const skills = testAddition(3, 4)
expect(skills).toContain('fiveComplements.4=5-1')
})
it('detects five complement when adding 3 to 4', () => {
// 4 + 3 = 7: heaven bead not active (4 < 5), result needs heaven bead
// Correct technique: +5, -2 (five complement)
const skills = testAddition(4, 3)
expect(skills).toContain('fiveComplements.3=5-2')
})
it('does NOT detect five complement when heaven bead already active', () => {
// This is the bug fix: 52 + 37 ones column = 2 + 7 = 9
// But when adding term 37 to running sum 52, the ones column sees:
// currentDigit = 2 (from 52), termDigit = 7
// Result = 9, which is > 5 but heaven bead is NOT active yet
// So this SHOULD use five complement
// The ACTUAL buggy case was: currentDigit >= 5 (heaven bead active)
// When adding 1-4 results in 6-9, should NOT use five complement
// Example: 7 + 2 = 9
// currentDigit = 7 (heaven bead active), termDigit = 2
// Just add 2 earth beads directly - no five complement needed
const skills = testAddition(7, 2)
expect(skills).not.toContain('fiveComplements.2=5-3')
expect(skills).toContain('basic.heavenBead')
expect(skills).toContain('basic.simpleCombinations')
})
it('does NOT detect five complement when 5 + 3 = 8', () => {
// 5 + 3 = 8: heaven bead already active (5 >= 5)
// Just add 3 earth beads - no five complement needed
const skills = testAddition(5, 3)
expect(skills).not.toContain('fiveComplements.3=5-2')
expect(skills).toContain('basic.heavenBead')
expect(skills).toContain('basic.simpleCombinations')
})
it('does NOT detect five complement when 6 + 3 = 9', () => {
// 6 + 3 = 9: heaven bead already active (6 >= 5)
// Just add 3 earth beads - no five complement needed
const skills = testAddition(6, 3)
expect(skills).not.toContain('fiveComplements.3=5-2')
expect(skills).toContain('basic.heavenBead')
expect(skills).toContain('basic.simpleCombinations')
})
it('does NOT detect five complement when 8 + 1 = 9', () => {
// 8 + 1 = 9: heaven bead already active (8 >= 5)
// Just add 1 earth bead - no five complement needed
const skills = testAddition(8, 1)
expect(skills).not.toContain('fiveComplements.1=5-4')
expect(skills).toContain('basic.heavenBead')
expect(skills).toContain('basic.simpleCombinations')
})
})
describe('direct addition (1-4 earth beads)', () => {
it('detects direct addition when adding 1-4 and staying under 5', () => {
// 1 + 2 = 3: just add earth beads
const skills = testAddition(1, 2)
expect(skills).toContain('basic.directAddition')
})
it('detects direct addition when adding to 0', () => {
// 0 + 4 = 4: just add earth beads
const skills = testAddition(0, 4)
expect(skills).toContain('basic.directAddition')
})
})
describe('heaven bead addition (5)', () => {
it('detects heaven bead when adding exactly 5', () => {
// 0 + 5 = 5: activate heaven bead
const skills = testAddition(0, 5)
expect(skills).toContain('basic.heavenBead')
})
it('detects heaven bead + simple combinations for 6-9', () => {
// 0 + 7 = 7: activate heaven bead + 2 earth beads
const skills = testAddition(0, 7)
expect(skills).toContain('basic.heavenBead')
expect(skills).toContain('basic.simpleCombinations')
})
})
})
describe('analyzeColumnSubtraction', () => {
describe('five complement in ten complement subtraction', () => {
it('does NOT detect five complement when heaven bead already active during ten complement', () => {
// Subtracting when currentDigit >= 5 and adding ten complement crosses 5 boundary
// Example: currentDigit = 7, termDigit = 9, needs borrow
// Ten complement: -9 = +1 - 10
// 7 + 1 = 8 (crosses 5 boundary but heaven bead already active)
// Should NOT push five complement for the +1 part
const skills = analyzeColumnSubtraction(7, 9, true)
expect(skills).toContain('tenComplementsSub.-9=+1-10')
expect(skills).not.toContain('fiveComplements.1=5-4')
})
it('does NOT detect five complement when 6 + 3 during ten complement', () => {
// currentDigit = 6, termDigit = 7, needs borrow
// Ten complement: -7 = +3 - 10
// 6 + 3 = 9 (crosses 5 boundary but heaven bead already active at 6)
const skills = analyzeColumnSubtraction(6, 7, true)
expect(skills).toContain('tenComplementsSub.-7=+3-10')
expect(skills).not.toContain('fiveComplements.3=5-2')
})
it('DOES detect five complement when heaven bead NOT active during ten complement', () => {
// currentDigit = 3, termDigit = 7, needs borrow
// Ten complement: -7 = +3 - 10
// 3 + 3 = 6 (crosses 5 boundary and heaven bead NOT active)
// SHOULD push five complement for the +3 part
const skills = analyzeColumnSubtraction(3, 7, true)
expect(skills).toContain('tenComplementsSub.-7=+3-10')
expect(skills).toContain('fiveComplements.3=5-2')
})
it('DOES detect five complement when 4 + 2 during ten complement', () => {
// currentDigit = 4, termDigit = 8, needs borrow
// Ten complement: -8 = +2 - 10
// 4 + 2 = 6 (crosses 5 boundary and heaven bead NOT active at 4)
const skills = analyzeColumnSubtraction(4, 8, true)
expect(skills).toContain('tenComplementsSub.-8=+2-10')
expect(skills).toContain('fiveComplements.2=5-3')
})
})
describe('direct subtraction', () => {
it('detects direct subtraction when enough earth beads available', () => {
// 7 - 2 = 5: have 2 earth beads (7 % 5 = 2), can subtract 2 directly
const skills = analyzeColumnSubtraction(7, 2, false)
expect(skills).toContain('basic.directSubtraction')
})
it('detects five complement subtraction when not enough earth beads', () => {
// 6 - 3 = 3: have 1 earth bead (6 % 5 = 1), need 3
// Use five complement: -3 = -5 + 2
const skills = analyzeColumnSubtraction(6, 3, false)
expect(skills).toContain('fiveComplementsSub.-3=-5+2')
expect(skills).toContain('basic.heavenBeadSubtraction')
})
})
describe('heaven bead subtraction', () => {
it('detects heaven bead subtraction when subtracting exactly 5', () => {
// 7 - 5 = 2: just remove heaven bead
const skills = analyzeColumnSubtraction(7, 5, false)
expect(skills).toContain('basic.heavenBeadSubtraction')
})
it('detects combination subtraction for 6-9', () => {
// 9 - 7 = 2: remove heaven bead and 2 earth beads
const skills = analyzeColumnSubtraction(9, 7, false)
expect(skills).toContain('basic.heavenBeadSubtraction')
expect(skills).toContain('basic.simpleCombinationsSub')
})
})
})
describe('real-world problem: 52 + 37 = 89', () => {
it('ones column (2 + 7 = 9) should use heaven bead pattern, NOT five complement', () => {
// This is the exact bug scenario from the user report
// Running sum = 52, adding term = 37
// Ones column: currentDigit = 2, termDigit = 7
// 2 + 7 = 9, no carry needed
// Result is 9, heaven bead NOT active initially (2 < 5)
// Need to activate heaven bead and add 4 earth beads
// The bug was in detecting fiveComplements.3=5-2 incorrectly
// Let's verify the correct behavior:
const skills = testAddition(2, 7)
// Result 9 requires heaven bead + 4 earth beads
// Adding 7 is in the 6-9 range, so it uses heaven bead + earth beads pattern
expect(skills).toContain('basic.heavenBead')
expect(skills).toContain('basic.simpleCombinations')
// Should NOT incorrectly detect five complement
// Adding 7 doesn't use five complement - it's direct heaven bead + earth beads
expect(skills).not.toContain('fiveComplements.3=5-2')
})
it('tens column (5 + 3 = 8) should NOT use five complement since heaven bead active', () => {
// Running sum = 52, adding term = 37
// Tens column: currentDigit = 5, termDigit = 3
// 5 + 3 = 8, heaven bead already active (5 >= 5)
// Just add 3 earth beads - no five complement needed
const skills = testAddition(5, 3)
expect(skills).toContain('basic.heavenBead')
expect(skills).toContain('basic.simpleCombinations')
// Should NOT detect five complement since heaven bead is already active
expect(skills).not.toContain('fiveComplements.3=5-2')
})
})

View File

@ -199,7 +199,7 @@ export function analyzeStepSkills(currentValue: number, term: number, newValue:
/**
* Analyzes skills needed for addition in a single column
*/
function analyzeColumnAddition(
export function analyzeColumnAddition(
currentDigit: number,
termDigit: number,
_resultDigit: number,
@ -221,10 +221,17 @@ function analyzeColumnAddition(
skills.push('basic.heavenBead')
}
} else if (currentDigit + termDigit > 5 && currentDigit + termDigit <= 9) {
// Results in 6-9: use five complement + simple combination
skills.push(`fiveComplements.${termDigit}=5-${5 - termDigit}`)
skills.push('basic.heavenBead')
skills.push('basic.simpleCombinations')
// Results in 6-9
// If heaven bead already active (currentDigit >= 5), just add earth beads directly
if (currentDigit >= 5) {
skills.push('basic.heavenBead')
skills.push('basic.simpleCombinations')
} else {
// Heaven bead NOT active - need five complement to bring it down
skills.push(`fiveComplements.${termDigit}=5-${5 - termDigit}`)
skills.push('basic.heavenBead')
skills.push('basic.simpleCombinations')
}
} else if (currentDigit + termDigit >= 10) {
// Ten complement needed
const complement = 10 - termDigit
@ -323,18 +330,18 @@ export function analyzeColumnSubtraction(
if (tenComplement >= 1 && tenComplement <= 4) {
// Adding 1-4 to currentDigit
if (currentDigit + tenComplement <= 4) {
// Direct addition of complement
skills.push(`tenComplementsSub.-${termDigit}=+${tenComplement}-10`)
} else if (currentDigit + tenComplement >= 5 && afterAddition <= 9) {
// Adding complement crosses 5 boundary - need five complement for the addition part
// Combined technique: use five complement to add the ten complement
skills.push(`tenComplementsSub.-${termDigit}=+${tenComplement}-10`)
skills.push(`fiveComplements.${tenComplement}=5-${5 - tenComplement}`)
} else {
// Simple ten complement
skills.push(`tenComplementsSub.-${termDigit}=+${tenComplement}-10`)
skills.push(`tenComplementsSub.-${termDigit}=+${tenComplement}-10`)
if (currentDigit + tenComplement >= 5 && afterAddition <= 9) {
// Adding complement crosses 5 boundary
if (currentDigit < 5) {
// Heaven bead NOT active - need five complement for the addition part
// Combined technique: use five complement to add the ten complement
skills.push(`fiveComplements.${tenComplement}=5-${5 - tenComplement}`)
}
// If currentDigit >= 5, heaven bead already active - just add earth beads directly
}
// If result <= 4, just direct addition of earth beads
} else if (tenComplement === 5) {
// -5 = +5-10
skills.push(`tenComplementsSub.-5=+5-10`)