feat(practice): add cascading regrouping skills and improve help UX

- Add advanced.cascadingCarry and advanced.cascadingBorrow skills for
  detecting when carry/borrow propagates across 2+ consecutive columns
  (e.g., 999 + 1 = 1000 or 1000 - 1 = 999)
- Update VerticalProblem help UX: replace answer boxes with help abacus
  instead of floating above terms (less confusing for kids)
- Dim terms already in prefix sum at 40% opacity when in help mode
- Enlarge current-help arrow indicator to 1.75rem
- Add "Advanced Multi-Column Operations" category to ManualSkillSelector
  so teachers can manually enable these skills
- Add unit tests for cascading regrouping detection (21 tests)

🤖 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-10 11:19:09 -06:00
parent 5cfbeeb8df
commit 7cf689c3d9
7 changed files with 370 additions and 141 deletions

View File

@ -94,6 +94,7 @@ const SKILL_CATEGORY_NAMES: Record<string, string> = {
fiveComplementsSub: '5-Complements (Sub)',
tenComplements: '10-Complements',
tenComplementsSub: '10-Complements (Sub)',
advanced: 'Advanced',
}
/**

View File

@ -67,6 +67,13 @@ const SKILL_CATEGORIES = {
'-1=+9-10': '-1 = +9 - 10',
},
},
advanced: {
name: 'Advanced Multi-Column Operations',
skills: {
cascadingCarry: 'Cascading Carry (e.g., 999 + 1 = 1000)',
cascadingBorrow: 'Cascading Borrow (e.g., 1000 - 1 = 999)',
},
},
} as const
type CategoryKey = keyof typeof SKILL_CATEGORIES

View File

@ -364,7 +364,7 @@ function TermsPlayground() {
? {
terms,
answer: correctAnswer,
skillsUsed: [],
skillsRequired: [],
}
: null
@ -386,7 +386,7 @@ function TermsPlayground() {
const newAnswer = newTerms.reduce((sum, t) => sum + t, 0)
if (newTerms.length > 0) {
interaction.loadProblem(
{ terms: newTerms, answer: newAnswer, skillsUsed: [] },
{ terms: newTerms, answer: newAnswer, skillsRequired: [] },
0,
problemKey + 1
)
@ -421,7 +421,11 @@ function TermsPlayground() {
const handleReset = () => {
if (terms.length > 0) {
interaction.loadProblem({ terms, answer: correctAnswer, skillsUsed: [] }, 0, problemKey + 1)
interaction.loadProblem(
{ terms, answer: correctAnswer, skillsRequired: [] },
0,
problemKey + 1
)
setProblemKey((k) => k + 1)
}
}
@ -531,8 +535,7 @@ function TermsPlayground() {
interaction.ambiguousHelpTermIndex === i + 1 ? 'yellow.200' : 'blue.100',
padding: '0.125rem 0.375rem',
borderRadius: '4px',
border:
interaction.ambiguousHelpTermIndex === i + 1 ? '2px solid' : '1px solid',
border: interaction.ambiguousHelpTermIndex === i + 1 ? '2px solid' : '1px solid',
borderColor:
interaction.ambiguousHelpTermIndex === i + 1 ? 'yellow.500' : 'transparent',
})}
@ -654,7 +657,9 @@ function TermsPlayground() {
>
<strong>How to test help detection:</strong>
<ul className={css({ marginTop: '0.5rem', paddingLeft: '1.25rem' })}>
<li>Type a prefix sum value (e.g., "100" for the first term) to trigger disambiguation</li>
<li>
Type a prefix sum value (e.g., "100" for the first term) to trigger disambiguation
</li>
<li>Wait 4 seconds for auto-help, or keep typing to continue to final answer</li>
<li>
Type with leading zero (e.g., "0100" or "063") to <em>immediately</em> request help

View File

@ -23,7 +23,7 @@ interface VerticalProblemProps {
needHelpTermIndex?: number
/** Rejected digit to show as red X (null = no rejection) */
rejectedDigit?: string | null
/** Help overlay to render adjacent to the current help term (positioned above the term row) */
/** Help overlay to render in place of answer boxes when in help mode */
helpOverlay?: ReactNode
}
@ -132,6 +132,8 @@ export function VerticalProblem({
const isCurrentHelp = index === currentHelpTermIndex
// Check if this term row should show "need help?" prompt (ambiguous case)
const showNeedHelp = index === needHelpTermIndex && !isCurrentHelp
// Check if this term is already included in the prefix sum (when in help mode)
const isInPrefixSum = currentHelpTermIndex !== undefined && index < currentHelpTermIndex
return (
<div
@ -144,6 +146,8 @@ export function VerticalProblem({
gap: '2px',
position: 'relative',
transition: 'all 0.2s ease',
// Dim terms already in prefix sum when in help mode
opacity: isInPrefixSum ? 0.4 : 1,
})}
>
{/* "Need help?" prompt for ambiguous prefix case */}
@ -190,9 +194,9 @@ export function VerticalProblem({
data-element="current-arrow"
className={css({
position: 'absolute',
left: '-1.5rem',
left: '-2.2rem',
color: isDark ? 'purple.300' : 'purple.600',
fontSize: '0.875rem',
fontSize: '1.75rem',
})}
>
@ -238,24 +242,6 @@ export function VerticalProblem({
{digit}
</div>
))}
{/* Help overlay - positioned above this term row, translated up by its height */}
{isCurrentHelp && helpOverlay && (
<div
data-section="term-help"
data-help-term-index={index}
className={css({
position: 'absolute',
// Position at bottom of this row, then translate up by 100% to sit above it
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
zIndex: 10,
})}
>
{helpOverlay}
</div>
)}
</div>
)
})}
@ -272,133 +258,150 @@ export function VerticalProblem({
})}
/>
{/* Answer row */}
<div
data-element="answer-row"
className={css({
display: 'flex',
alignItems: 'center',
gap: '2px',
position: 'relative',
})}
>
{/* Equals column */}
{/* Answer row - shows help abacus when in help mode, otherwise answer cells */}
{currentHelpTermIndex !== undefined && helpOverlay ? (
// Help mode: show the help abacus in place of answer boxes
<div
data-element="equals"
data-element="help-area"
data-help-term-index={currentHelpTermIndex}
className={css({
width: cellWidth,
height: cellHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isDark ? 'gray.400' : 'gray.500',
padding: '0.5rem 0',
})}
>
=
{helpOverlay}
</div>
) : (
// Normal mode: show answer digit cells
<div
data-element="answer-row"
className={css({
display: 'flex',
alignItems: 'center',
gap: '2px',
position: 'relative',
})}
>
{/* Equals column */}
<div
data-element="equals"
className={css({
width: cellWidth,
height: cellHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: isDark ? 'gray.400' : 'gray.500',
})}
>
=
</div>
{/* Answer digit cells - show maxDigits cells total */}
{Array(maxDigits)
.fill(null)
.map((_, index) => {
// Determine what to show in this cell
const displayValue =
isCompleted && isIncorrect ? correctAnswer?.toString() || '' : userAnswer
const paddedValue = displayValue.padStart(maxDigits, '')
const digit = paddedValue[index] || ''
const isEmpty = digit === ''
{/* Answer digit cells - show maxDigits cells total */}
{Array(maxDigits)
.fill(null)
.map((_, index) => {
// Determine what to show in this cell
const displayValue =
isCompleted && isIncorrect ? correctAnswer?.toString() || '' : userAnswer
const paddedValue = displayValue.padStart(maxDigits, '')
const digit = paddedValue[index] || ''
const isEmpty = digit === ''
// Check if this is the cell where a rejected digit should show
// Digits are entered left-to-right, filling from left side of the answer area
// So the next digit position is right after the current answer length
const nextDigitIndex = userAnswer.length
const isRejectedCell = rejectedDigit && isEmpty && index === nextDigitIndex
// Check if this is the cell where a rejected digit should show
// Digits are entered left-to-right, filling from left side of the answer area
// So the next digit position is right after the current answer length
const nextDigitIndex = userAnswer.length
const isRejectedCell = rejectedDigit && isEmpty && index === nextDigitIndex
return (
<div
key={index}
data-element={
isRejectedCell ? 'rejected-cell' : isEmpty ? 'empty-cell' : 'answer-cell'
}
className={css({
width: cellWidth,
height: cellHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
backgroundColor: isRejectedCell
? isDark
? 'red.900'
: 'red.100'
: isCompleted
? isCorrect
? isDark
? 'green.800'
: 'green.100'
return (
<div
key={index}
data-element={
isRejectedCell ? 'rejected-cell' : isEmpty ? 'empty-cell' : 'answer-cell'
}
className={css({
width: cellWidth,
height: cellHeight,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
backgroundColor: isRejectedCell
? isDark
? 'red.900'
: 'red.100'
: isCompleted
? isCorrect
? isDark
? 'green.800'
: 'green.100'
: isDark
? 'red.800'
: 'red.100'
: isDark
? 'red.800'
: 'red.100'
: isDark
? 'gray.700'
: 'white',
borderRadius: '4px',
border: isEmpty && !isCompleted && !isRejectedCell ? '1px dashed' : '1px solid',
borderColor: isRejectedCell
? isDark
? 'red.500'
: 'red.400'
: isCompleted
? isCorrect
? isDark
? 'green.600'
: 'green.300'
: isDark
? 'red.600'
: 'red.300'
: isEmpty
? isFocused
? 'blue.400'
? 'gray.700'
: 'white',
borderRadius: '4px',
border: isEmpty && !isCompleted && !isRejectedCell ? '1px dashed' : '1px solid',
borderColor: isRejectedCell
? isDark
? 'red.500'
: 'red.400'
: isCompleted
? isCorrect
? isDark
? 'green.600'
: 'green.300'
: isDark
? 'red.600'
: 'red.300'
: isEmpty
? isFocused
? 'blue.400'
: isDark
? 'gray.600'
: 'gray.300'
: isDark
? 'gray.600'
: 'gray.300'
: 'gray.300',
transition: 'all 0.15s ease-out',
color: isCompleted
? isCorrect
? isDark
? 'green.200'
: 'green.700'
: isDark
? 'gray.600'
: 'gray.300',
transition: 'all 0.15s ease-out',
color: isCompleted
? isCorrect
? isDark
? 'green.200'
: 'green.700'
? 'red.200'
: 'red.700'
: isDark
? 'red.200'
: 'red.700'
: isDark
? 'gray.200'
: 'gray.800',
// Shake animation for rejected cell
...(isRejectedCell && {
animation: 'shake 0.3s ease-out',
}),
})}
>
{isRejectedCell ? (
<span
className={css({
color: isDark ? 'red.400' : 'red.600',
fontWeight: 'bold',
})}
>
</span>
) : (
digit
)}
</div>
)
})}
</div>
? 'gray.200'
: 'gray.800',
// Shake animation for rejected cell
...(isRejectedCell && {
animation: 'shake 0.3s ease-out',
}),
})}
>
{isRejectedCell ? (
<span
className={css({
color: isDark ? 'red.400' : 'red.600',
fontWeight: 'bold',
})}
>
</span>
) : (
digit
)}
</div>
)
})}
</div>
)}
{/* Show user's incorrect answer below correct answer */}
{isCompleted && isIncorrect && (

View File

@ -89,6 +89,14 @@ export interface SkillSet {
heavenBeadSubtraction: boolean // Can remove heaven bead (5)
simpleCombinationsSub: boolean // Can do 6-9 subtraction without complements
}
// Advanced operations involving multiple place values
advanced: {
/** Carry cascades across 2+ columns (e.g., 999 + 1 = 1000) */
cascadingCarry: boolean
/** Borrow cascades across 2+ columns (e.g., 1000 - 1 = 999) */
cascadingBorrow: boolean
}
}
export interface PracticeStep {
@ -168,6 +176,10 @@ export function createEmptySkillSet(): SkillSet {
'-2=+8-10': false,
'-1=+9-10': false,
},
advanced: {
cascadingCarry: false,
cascadingBorrow: false,
},
}
}
@ -215,6 +227,10 @@ export function createBasicSkillSet(): SkillSet {
'-2=+8-10': false,
'-1=+9-10': false,
},
advanced: {
cascadingCarry: false,
cascadingBorrow: false,
},
}
}

View File

@ -0,0 +1,143 @@
import { describe, expect, it } from 'vitest'
import { analyzeRequiredSkills, analyzeSubtractionStepSkills } from '../problemGenerator'
describe('cascading regrouping detection', () => {
describe('cascading carry (addition)', () => {
it('detects cascading carry when 999 + 1 = 1000', () => {
// Adding 1 to 999 causes carry to cascade through 3 columns
// 9+1=10 (carry), 9+1=10 (carry), 9+1=10 (carry) -> 1000
const skills = analyzeRequiredSkills([999, 1], 1000)
expect(skills).toContain('advanced.cascadingCarry')
})
it('detects cascading carry when 99 + 1 = 100', () => {
// Adding 1 to 99 causes carry through 2 columns (minimum for cascade)
const skills = analyzeRequiredSkills([99, 1], 100)
expect(skills).toContain('advanced.cascadingCarry')
})
it('does NOT detect cascading carry for 89 + 1 = 90', () => {
// Only one column carries (9+1=10), tens column has 8+1=9 (no carry)
const skills = analyzeRequiredSkills([89, 1], 90)
expect(skills).not.toContain('advanced.cascadingCarry')
})
it('does NOT detect cascading carry for 19 + 1 = 20', () => {
// Only one column carries (9+1=10), tens column 1+1=2 (no cascade)
const skills = analyzeRequiredSkills([19, 1], 20)
expect(skills).not.toContain('advanced.cascadingCarry')
})
it('does NOT detect cascading carry for simple addition 5 + 3 = 8', () => {
const skills = analyzeRequiredSkills([5, 3], 8)
expect(skills).not.toContain('advanced.cascadingCarry')
})
it('detects cascading carry for 199 + 1 = 200', () => {
// 9+1=10 (carry), 9+1=10 (carry) -> cascading
const skills = analyzeRequiredSkills([199, 1], 200)
expect(skills).toContain('advanced.cascadingCarry')
})
it('detects cascading carry for 9999 + 1 = 10000', () => {
// Four consecutive carries
const skills = analyzeRequiredSkills([9999, 1], 10000)
expect(skills).toContain('advanced.cascadingCarry')
})
it('detects cascading carry in multi-term problem', () => {
// 98 + 1 + 1 = 100
// First step: 98 + 1 = 99 (no cascade)
// Second step: 99 + 1 = 100 (cascading carry)
const skills = analyzeRequiredSkills([98, 1, 1], 100)
expect(skills).toContain('advanced.cascadingCarry')
})
})
describe('cascading borrow (subtraction)', () => {
it('detects cascading borrow when 1000 - 1 = 999', () => {
// Subtracting 1 from 1000: ones column 0-1 needs to borrow from tens,
// tens column is 0 so borrows from hundreds, hundreds is 0 so borrows from thousands
const skills = analyzeSubtractionStepSkills(1000, 1, 999)
expect(skills).toContain('advanced.cascadingBorrow')
})
it('detects cascading borrow when 100 - 1 = 99', () => {
// 0-1 borrows from tens, tens is 0 so borrows from hundreds
const skills = analyzeSubtractionStepSkills(100, 1, 99)
expect(skills).toContain('advanced.cascadingBorrow')
})
it('does NOT detect cascading borrow for 90 - 1 = 89', () => {
// 0-1 borrows from tens, tens has 9 (no cascade needed)
const skills = analyzeSubtractionStepSkills(90, 1, 89)
expect(skills).not.toContain('advanced.cascadingBorrow')
})
it('does NOT detect cascading borrow for 20 - 1 = 19', () => {
// Single borrow from tens column which has 2
const skills = analyzeSubtractionStepSkills(20, 1, 19)
expect(skills).not.toContain('advanced.cascadingBorrow')
})
it('does NOT detect cascading borrow for simple subtraction 8 - 3 = 5', () => {
const skills = analyzeSubtractionStepSkills(8, 3, 5)
expect(skills).not.toContain('advanced.cascadingBorrow')
})
it('detects cascading borrow for 200 - 1 = 199', () => {
// 0-1 borrows from tens (0), tens borrows from hundreds
const skills = analyzeSubtractionStepSkills(200, 1, 199)
expect(skills).toContain('advanced.cascadingBorrow')
})
it('detects cascading borrow for 10000 - 1 = 9999', () => {
// Four consecutive borrows
const skills = analyzeSubtractionStepSkills(10000, 1, 9999)
expect(skills).toContain('advanced.cascadingBorrow')
})
it('detects cascading borrow for 300 - 5 = 295', () => {
// 0-5 borrows from tens (0), tens borrows from hundreds
const skills = analyzeSubtractionStepSkills(300, 5, 295)
expect(skills).toContain('advanced.cascadingBorrow')
})
it('detects cascading borrow in analyzeRequiredSkills with negative term', () => {
// 1000 + (-1) = 999 should detect cascading borrow
const skills = analyzeRequiredSkills([1000, -1], 999)
expect(skills).toContain('advanced.cascadingBorrow')
})
})
describe('edge cases', () => {
it('handles 1000 - 999 = 1 (no cascade - single borrow per column)', () => {
// Each column may borrow but they don't cascade consecutively
const skills = analyzeSubtractionStepSkills(1000, 999, 1)
// This is actually more complex - let's check what happens
// 0-9 borrows, 0-9 borrows (but 0-1=9 from the borrow), etc.
// This might still be considered cascading since multiple borrows happen
// The key is consecutive columns needing to borrow
expect(skills).toContain('advanced.cascadingBorrow')
})
it('does not count single carries as cascading', () => {
// 45 + 5 = 50 - single carry from ones to tens
const skills = analyzeRequiredSkills([45, 5], 50)
expect(skills).not.toContain('advanced.cascadingCarry')
})
it('does not count single borrows as cascading', () => {
// 50 - 5 = 45 - single borrow needed
const skills = analyzeSubtractionStepSkills(50, 5, 45)
expect(skills).not.toContain('advanced.cascadingBorrow')
})
it('detects cascading carry even when not all columns carry', () => {
// 1099 + 1 = 1100: 9+1=10 (carry), 9+1=10 (carry), but 0+1=1 (no carry)
// Still has 2 consecutive carries in ones and tens
const skills = analyzeRequiredSkills([1099, 1], 1100)
expect(skills).toContain('advanced.cascadingCarry')
})
})
})

View File

@ -150,6 +150,7 @@ function generateStepExplanation(
/**
* Analyzes skills needed for a single addition step: currentValue + term = newValue
* Also detects cascading carries (when a carry propagates across 2+ columns).
*/
function analyzeStepSkills(currentValue: number, term: number, newValue: number): string[] {
const skills: string[] = []
@ -161,18 +162,42 @@ function analyzeStepSkills(currentValue: number, term: number, newValue: number)
const maxColumns = Math.max(currentDigits.length, termDigits.length, newDigits.length)
// Track carries for cascading detection
let carryIn = 0
let consecutiveCarries = 0
let maxConsecutiveCarries = 0
for (let column = 0; column < maxColumns; column++) {
const currentDigit = currentDigits[column] || 0
const termDigit = termDigits[column] || 0
const newDigit = newDigits[column] || 0
if (termDigit === 0) continue // No addition in this column
// Check if this column produces a carry (including any carry-in from previous column)
const sumInColumn = currentDigit + termDigit + carryIn
const producesCarry = sumInColumn >= 10
if (producesCarry) {
consecutiveCarries++
maxConsecutiveCarries = Math.max(maxConsecutiveCarries, consecutiveCarries)
carryIn = 1
} else {
// Reset consecutive carries when a column doesn't produce a carry
consecutiveCarries = 0
carryIn = 0
}
if (termDigit === 0 && carryIn === 0) continue // No addition in this column (and no carry to process)
// Analyze what happens in this column
const columnSkills = analyzeColumnAddition(currentDigit, termDigit, newDigit, column)
skills.push(...columnSkills)
}
// If we had 2+ consecutive carries, this is a cascading carry
if (maxConsecutiveCarries >= 2) {
skills.push('advanced.cascadingCarry')
}
return skills
}
@ -332,7 +357,8 @@ export function analyzeColumnSubtraction(
/**
* Analyzes skills needed for a single subtraction step: currentValue - term = newValue
* Works column by column from right to left, tracking borrows
* Works column by column from right to left, tracking borrows.
* Also detects cascading borrows (when a borrow propagates across 2+ columns).
*/
export function analyzeSubtractionStepSkills(
currentValue: number,
@ -350,6 +376,9 @@ export function analyzeSubtractionStepSkills(
// Track borrows as we work from right to left
let pendingBorrow = false
// Track consecutive borrows for cascading detection
let consecutiveBorrows = 0
for (let column = 0; column < maxColumns; column++) {
let currentDigit = currentDigits[column] || 0
const termDigit = termDigits[column] || 0
@ -367,7 +396,11 @@ export function analyzeSubtractionStepSkills(
if (needsBorrow) {
pendingBorrow = true
consecutiveBorrows++
// After borrowing, we effectively have currentDigit + 10
} else {
// Reset consecutive borrows when a column doesn't need to borrow
consecutiveBorrows = 0
}
// Analyze skills needed for this column
@ -379,6 +412,11 @@ export function analyzeSubtractionStepSkills(
skills.push(...columnSkills)
}
// If we had 2+ consecutive borrows, this is a cascading borrow
if (consecutiveBorrows >= 2) {
skills.push('advanced.cascadingBorrow')
}
return [...new Set(skills)] // Remove duplicates
}
@ -411,6 +449,8 @@ function isSkillEnabled(skillPath: string, skillSet: SkillSet | Partial<SkillSet
return skillSet.fiveComplementsSub[skill as keyof typeof skillSet.fiveComplementsSub] || false
} else if (category === 'tenComplementsSub' && skillSet.tenComplementsSub) {
return skillSet.tenComplementsSub[skill as keyof typeof skillSet.tenComplementsSub] || false
} else if (category === 'advanced' && skillSet.advanced) {
return skillSet.advanced[skill as keyof typeof skillSet.advanced] || false
}
return false
}
@ -452,7 +492,8 @@ export function problemMatchesSkills(
Object.values(targetSkills.fiveComplements || {}).some(Boolean) ||
Object.values(targetSkills.tenComplements || {}).some(Boolean) ||
Object.values(targetSkills.fiveComplementsSub || {}).some(Boolean) ||
Object.values(targetSkills.tenComplementsSub || {}).some(Boolean)
Object.values(targetSkills.tenComplementsSub || {}).some(Boolean) ||
Object.values(targetSkills.advanced || {}).some(Boolean)
if (hasAnyTargetSkill && !hasTargetSkill) return false
}
@ -780,6 +821,19 @@ function generateSequentialExplanation(terms: number[], sum: number, skills: str
)
}
// Advanced skill explanations
if (skills.includes('advanced.cascadingCarry')) {
explanations.push(
'This problem involves cascading carry (carry propagates across 2+ place values).'
)
}
if (skills.includes('advanced.cascadingBorrow')) {
explanations.push(
'This problem involves cascading borrow (borrow propagates across 2+ place values).'
)
}
return explanations.join(' ')
}