feat(tutorial): implement subtraction in unified step generator

Add complete subtraction support to the decomposition system:

- Direct subtraction: remove beads when sufficient
- Five's complement subtraction: -d = -5 + (5-d)
- Ten's complement subtraction (borrow): -d = +(10-d) - 10
- Multi-digit subtraction with left-to-right processing
- Cascade borrow through consecutive zeros

Also adds:
- Comprehensive architecture documentation
- Subtraction implementation plan with design decisions
- Decomposition audit story for testing all operation types
- Skill extraction functions for subtraction skills

🤖 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 09:31:13 -06:00
parent 4f7a9d76cd
commit e5c697b7a8
5 changed files with 3045 additions and 193 deletions

View File

@ -0,0 +1,975 @@
/**
* Decomposition System Audit Story
*
* Comprehensive testing for the unified step generator and decomposition display.
* Test all operation types: addition, subtraction, five complements, ten complements, cascades.
*/
import type { Meta, StoryObj } from '@storybook/react'
import { useCallback, useMemo, useState } from 'react'
import { DecompositionProvider } from '@/contexts/DecompositionContext'
import {
generateUnifiedInstructionSequence,
type PedagogicalRule,
type PedagogicalSegment,
type UnifiedInstructionSequence,
} from '@/utils/unifiedStepGenerator'
import { css } from '../../../styled-system/css'
import { HelpAbacus } from '../practice/HelpAbacus'
import { DecompositionDisplay } from './DecompositionDisplay'
const meta: Meta = {
title: 'Practice/Decomposition System Audit',
parameters: {
layout: 'fullscreen',
},
}
export default meta
// Test case categories
interface TestCase {
name: string
start: number
target: number
expectedRules?: PedagogicalRule[]
description: string
}
const TEST_CATEGORIES: Record<string, TestCase[]> = {
'Basic Addition (Direct)': [
{
name: '+1 from 0',
start: 0,
target: 1,
expectedRules: ['Direct'],
description: 'Simplest case',
},
{
name: '+4 from 0',
start: 0,
target: 4,
expectedRules: ['Direct'],
description: 'Max direct add',
},
{
name: '+2 from 2',
start: 2,
target: 4,
expectedRules: ['Direct'],
description: 'Direct when room exists',
},
{
name: '+3 from 1',
start: 1,
target: 4,
expectedRules: ['Direct'],
description: 'Direct to fill earth beads',
},
],
'Basic Subtraction (Direct)': [
{
name: '-1 from 4',
start: 4,
target: 3,
expectedRules: ['Direct'],
description: 'Simple direct subtract',
},
{
name: '-3 from 4',
start: 4,
target: 1,
expectedRules: ['Direct'],
description: 'Larger direct subtract',
},
{
name: '-2 from 3',
start: 3,
target: 1,
expectedRules: ['Direct'],
description: 'Direct from middle',
},
],
'Five Complement Addition': [
{
name: '+4 from 1',
start: 1,
target: 5,
expectedRules: ['FiveComplement'],
description: '+4 = +5-1',
},
{
name: '+3 from 2',
start: 2,
target: 5,
expectedRules: ['FiveComplement'],
description: '+3 = +5-2',
},
{
name: '+4 from 2',
start: 2,
target: 6,
expectedRules: ['FiveComplement'],
description: '+4 = +5-1, result > 5',
},
{
name: '+3 from 3',
start: 3,
target: 6,
expectedRules: ['FiveComplement'],
description: '+3 = +5-2, result > 5',
},
{
name: '+2 from 4',
start: 4,
target: 6,
expectedRules: ['FiveComplement'],
description: '+2 = +5-3',
},
{
name: '+1 from 4',
start: 4,
target: 5,
expectedRules: ['FiveComplement'],
description: '+1 = +5-4',
},
],
'Five Complement Subtraction': [
{
name: '-4 from 5',
start: 5,
target: 1,
expectedRules: ['FiveComplement'],
description: '-4 = -5+1',
},
{
name: '-3 from 5',
start: 5,
target: 2,
expectedRules: ['FiveComplement'],
description: '-3 = -5+2',
},
{
name: '-2 from 5',
start: 5,
target: 3,
expectedRules: ['FiveComplement'],
description: '-2 = -5+3',
},
{
name: '-1 from 5',
start: 5,
target: 4,
expectedRules: ['FiveComplement'],
description: '-1 = -5+4',
},
{
name: '-4 from 6',
start: 6,
target: 2,
expectedRules: ['FiveComplement'],
description: '-4 = -5+1, from > 5',
},
{
name: '-3 from 7',
start: 7,
target: 4,
expectedRules: ['FiveComplement'],
description: '-3 = -5+2, from > 5',
},
],
'Ten Complement Addition (Carry)': [
{
name: '+9 from 1',
start: 1,
target: 10,
expectedRules: ['TenComplement'],
description: '+9 = +10-1',
},
{
name: '+8 from 2',
start: 2,
target: 10,
expectedRules: ['TenComplement'],
description: '+8 = +10-2',
},
{
name: '+7 from 5',
start: 5,
target: 12,
expectedRules: ['TenComplement'],
description: '+7 = +10-3',
},
{
name: '+6 from 6',
start: 6,
target: 12,
expectedRules: ['TenComplement'],
description: '+6 = +10-4',
},
{
name: '+5 from 5',
start: 5,
target: 10,
expectedRules: ['TenComplement'],
description: '+5 = +10-5',
},
{
name: '+9 from 5',
start: 5,
target: 14,
expectedRules: ['TenComplement'],
description: '+9 = +10-1, from 5',
},
],
'Ten Complement Subtraction (Borrow)': [
{
name: '-9 from 10',
start: 10,
target: 1,
expectedRules: ['TenComplement'],
description: '-9 = +1-10',
},
{
name: '-8 from 10',
start: 10,
target: 2,
expectedRules: ['TenComplement'],
description: '-8 = +2-10',
},
{
name: '-7 from 10',
start: 10,
target: 3,
expectedRules: ['TenComplement'],
description: '-7 = +3-10',
},
{
name: '-6 from 10',
start: 10,
target: 4,
expectedRules: ['TenComplement'],
description: '-6 = +4-10',
},
{
name: '-5 from 10',
start: 10,
target: 5,
expectedRules: ['TenComplement'],
description: '-5 = +5-10',
},
{
name: '-9 from 13',
start: 13,
target: 4,
expectedRules: ['TenComplement'],
description: '-9 = +1-10, from 13',
},
],
'Multi-digit Addition': [
{
name: '+10 from 0',
start: 0,
target: 10,
expectedRules: ['Direct'],
description: 'Add one tens bead',
},
{ name: '+25 from 0', start: 0, target: 25, description: 'Multiple places' },
{ name: '+14 from 3', start: 3, target: 17, description: 'Mix of rules' },
{ name: '+99 from 0', start: 0, target: 99, description: 'Max two-digit' },
{ name: '+45 from 23', start: 23, target: 68, description: 'Complex multi-digit' },
],
'Multi-digit Subtraction': [
{ name: '-10 from 15', start: 15, target: 5, description: 'Subtract tens' },
{ name: '-25 from 50', start: 50, target: 25, description: 'Multiple places' },
{ name: '-14 from 30', start: 30, target: 16, description: 'With borrowing' },
{ name: '-37 from 52', start: 52, target: 15, description: 'Complex borrowing' },
],
'Cascade Cases': [
{ name: '+1 from 9', start: 9, target: 10, description: 'Simple cascade' },
{ name: '+1 from 99', start: 99, target: 100, description: 'Double cascade' },
{ name: '+1 from 999', start: 999, target: 1000, description: 'Triple cascade' },
{ name: '+2 from 98', start: 98, target: 100, description: 'Cascade with +2' },
{ name: '+11 from 89', start: 89, target: 100, description: 'Multi-digit cascade' },
],
'Edge Cases': [
{ name: '0 → 0', start: 0, target: 0, description: 'No change' },
{ name: '5 → 5', start: 5, target: 5, description: 'No change at 5' },
{ name: '+5 from 0', start: 0, target: 5, description: 'Add heaven bead' },
{ name: '-5 from 5', start: 5, target: 0, description: 'Remove heaven bead' },
{ name: '+5 from 4', start: 4, target: 9, description: 'Add 5 when earth full' },
{ name: '-5 from 9', start: 9, target: 4, description: 'Remove 5 when earth full' },
],
'Mixed Operations (Practice Session Style)': [
{ name: '0 → 5 → 12', start: 0, target: 5, description: 'First of a sequence' },
{ name: '12 → 8', start: 12, target: 8, description: 'Subtract in sequence' },
{ name: '8 → 15', start: 8, target: 15, description: 'Add after subtract' },
{ name: '15 → 6', start: 15, target: 6, description: 'Large subtract' },
],
}
// Helper to get rule color
function getRuleColor(rule: PedagogicalRule): string {
switch (rule) {
case 'Direct':
return 'green.100'
case 'FiveComplement':
return 'blue.100'
case 'TenComplement':
return 'purple.100'
case 'Cascade':
return 'orange.100'
default:
return 'gray.100'
}
}
function getRuleEmoji(rule: PedagogicalRule): string {
switch (rule) {
case 'Direct':
return '✨'
case 'FiveComplement':
return '🤝'
case 'TenComplement':
return '🔟'
case 'Cascade':
return '🌊'
default:
return '❓'
}
}
// Single test case display
function TestCaseDisplay({
testCase,
isSelected,
onClick,
}: {
testCase: TestCase
isSelected: boolean
onClick: () => void
}) {
const sequence = useMemo(
() => generateUnifiedInstructionSequence(testCase.start, testCase.target),
[testCase.start, testCase.target]
)
const rules = sequence.segments.map((s) => s.plan[0]?.rule).filter(Boolean) as PedagogicalRule[]
const uniqueRules = [...new Set(rules)]
const hasIssues = sequence.steps.some((s) => !s.isValid)
return (
<button
type="button"
onClick={onClick}
className={css({
display: 'block',
w: '100%',
textAlign: 'left',
p: '0.5rem',
borderRadius: '6px',
border: '2px solid',
borderColor: isSelected ? 'blue.500' : hasIssues ? 'red.300' : 'gray.200',
bg: isSelected ? 'blue.50' : hasIssues ? 'red.50' : 'white',
cursor: 'pointer',
transition: 'all 0.15s',
_hover: { borderColor: isSelected ? 'blue.600' : 'blue.300' },
})}
>
<div
className={css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center' })}
>
<span className={css({ fontWeight: 'medium', fontSize: '0.875rem' })}>{testCase.name}</span>
<span className={css({ fontSize: '0.75rem', color: 'gray.500' })}>
{testCase.start} {testCase.target}
</span>
</div>
<div className={css({ display: 'flex', gap: '0.25rem', mt: '0.25rem', flexWrap: 'wrap' })}>
{uniqueRules.map((rule) => (
<span
key={rule}
className={css({
fontSize: '0.625rem',
px: '0.375rem',
py: '0.125rem',
borderRadius: '4px',
bg: getRuleColor(rule),
})}
>
{getRuleEmoji(rule)} {rule}
</span>
))}
{hasIssues && (
<span
className={css({
fontSize: '0.625rem',
px: '0.375rem',
py: '0.125rem',
borderRadius: '4px',
bg: 'red.200',
color: 'red.800',
})}
>
Issues
</span>
)}
</div>
</button>
)
}
// Detailed view for selected test case
function DetailedView({ start, target }: { start: number; target: number }) {
const { sequence, error } = useMemo(() => {
try {
return { sequence: generateUnifiedInstructionSequence(start, target), error: null }
} catch (e) {
return { sequence: null, error: e instanceof Error ? e.message : 'Unknown error' }
}
}, [start, target])
const [currentStep, setCurrentStep] = useState(0)
const difference = target - start
const operation = difference >= 0 ? 'Addition' : 'Subtraction'
// Show error state for unsupported operations
if (error || !sequence) {
return (
<div className={css({ p: '2rem', textAlign: 'center' })}>
<div
className={css({
p: '1.5rem',
bg: 'orange.50',
border: '2px solid',
borderColor: 'orange.300',
borderRadius: '12px',
maxWidth: '500px',
margin: '0 auto',
})}
>
<div className={css({ fontSize: '2rem', mb: '0.5rem' })}>🚧</div>
<h3
className={css({
fontSize: '1.125rem',
fontWeight: 'bold',
color: 'orange.800',
mb: '0.5rem',
})}
>
{operation} Not Yet Implemented
</h3>
<p className={css({ fontSize: '0.875rem', color: 'orange.700', mb: '1rem' })}>
{start} {target} ({difference >= 0 ? '+' : ''}
{difference})
</p>
<p className={css({ fontSize: '0.75rem', color: 'gray.600' })}>
{error || 'The decomposition system does not yet support this operation.'}
</p>
</div>
</div>
)
}
return (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1.5rem' })}>
{/* Header */}
<div className={css({ p: '1rem', bg: 'gray.50', borderRadius: '8px' })}>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<div>
<h3 className={css({ fontSize: '1.25rem', fontWeight: 'bold' })}>
{start} {target}
</h3>
<p className={css({ fontSize: '0.875rem', color: 'gray.600' })}>
{operation}: {difference >= 0 ? '+' : ''}
{difference}
</p>
</div>
<div className={css({ textAlign: 'right' })}>
<div className={css({ fontSize: '0.75rem', color: 'gray.500' })}>
{sequence.steps.length} steps, {sequence.segments.length} segments
</div>
<div
className={css({
fontSize: '0.75rem',
color: sequence.isMeaningfulDecomposition ? 'green.600' : 'gray.400',
})}
>
{sequence.isMeaningfulDecomposition ? '✓ Meaningful' : '○ Trivial'}
</div>
</div>
</div>
</div>
{/* Decomposition Display */}
<div
className={css({
p: '1rem',
bg: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '8px',
})}
>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
mb: '0.75rem',
color: 'gray.700',
})}
>
Decomposition
</h4>
<DecompositionProvider
startValue={start}
targetValue={target}
currentStepIndex={currentStep}
>
<DecompositionDisplay />
</DecompositionProvider>
<div
className={css({
mt: '0.5rem',
fontSize: '0.75rem',
color: 'gray.500',
fontFamily: 'monospace',
})}
>
Raw: {sequence.fullDecomposition}
</div>
</div>
{/* Interactive Abacus */}
<div
className={css({
p: '1rem',
bg: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '8px',
})}
>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
mb: '0.75rem',
color: 'gray.700',
})}
>
Interactive Abacus
</h4>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<HelpAbacus
currentValue={start}
targetValue={target}
columns={Math.max(3, Math.ceil(Math.log10(Math.max(start, target, 1) + 1)) + 1)}
scaleFactor={1.0}
interactive
/>
</div>
</div>
{/* Segments */}
<div
className={css({
p: '1rem',
bg: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '8px',
})}
>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
mb: '0.75rem',
color: 'gray.700',
})}
>
Pedagogical Segments ({sequence.segments.length})
</h4>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.75rem' })}>
{sequence.segments.map((segment, i) => (
<SegmentCard key={segment.id} segment={segment} index={i} />
))}
</div>
</div>
{/* Steps */}
<div
className={css({
p: '1rem',
bg: 'white',
border: '1px solid',
borderColor: 'gray.200',
borderRadius: '8px',
})}
>
<h4
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
mb: '0.75rem',
color: 'gray.700',
})}
>
Steps ({sequence.steps.length})
</h4>
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '0.5rem', mb: '0.75rem' })}>
{sequence.steps.map((step, i) => (
<button
key={step.stepIndex}
type="button"
onClick={() => setCurrentStep(i)}
className={css({
px: '0.75rem',
py: '0.375rem',
borderRadius: '6px',
border: '1px solid',
borderColor: currentStep === i ? 'blue.500' : step.isValid ? 'gray.300' : 'red.400',
bg: currentStep === i ? 'blue.100' : step.isValid ? 'white' : 'red.50',
cursor: 'pointer',
fontSize: '0.75rem',
fontFamily: 'monospace',
_hover: { borderColor: 'blue.400' },
})}
>
{step.mathematicalTerm}
</button>
))}
</div>
{sequence.steps[currentStep] && <StepDetail step={sequence.steps[currentStep]} />}
</div>
{/* Debug JSON */}
<details className={css({ p: '1rem', bg: 'gray.900', borderRadius: '8px' })}>
<summary className={css({ color: 'gray.400', cursor: 'pointer', fontSize: '0.75rem' })}>
Raw JSON (click to expand)
</summary>
<pre
className={css({
mt: '0.5rem',
fontSize: '0.625rem',
color: 'gray.300',
overflow: 'auto',
maxH: '400px',
})}
>
{JSON.stringify(sequence, null, 2)}
</pre>
</details>
</div>
)
}
function SegmentCard({ segment, index }: { segment: PedagogicalSegment; index: number }) {
const rule = segment.plan[0]?.rule || 'Direct'
return (
<div
className={css({
p: '0.75rem',
borderRadius: '6px',
border: '1px solid',
borderColor: 'gray.200',
bg: getRuleColor(rule),
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
})}
>
<div>
<div className={css({ fontWeight: 'bold', fontSize: '0.875rem' })}>
{getRuleEmoji(rule)} {segment.readable.title}
</div>
{segment.readable.subtitle && (
<div className={css({ fontSize: '0.75rem', color: 'gray.600' })}>
{segment.readable.subtitle}
</div>
)}
</div>
<div className={css({ fontSize: '0.625rem', color: 'gray.500', fontFamily: 'monospace' })}>
{segment.id}
</div>
</div>
<div className={css({ mt: '0.5rem', fontSize: '0.75rem' })}>
<strong>Summary:</strong> {segment.readable.summary}
</div>
<div className={css({ mt: '0.5rem', fontSize: '0.75rem' })}>
<strong>Goal:</strong> {segment.goal}
</div>
<div className={css({ mt: '0.5rem', fontSize: '0.75rem' })}>
<strong>Expression:</strong> <code>{segment.expression}</code>
</div>
<div className={css({ mt: '0.5rem', fontSize: '0.75rem' })}>
<strong>Value change:</strong> {segment.startValue} {segment.endValue}
</div>
{segment.readable.stepsFriendly.length > 0 && (
<div className={css({ mt: '0.5rem', fontSize: '0.75rem' })}>
<strong>Bead moves:</strong>
<ul className={css({ pl: '1rem', mt: '0.25rem' })}>
{segment.readable.stepsFriendly.map((step, i) => (
<li key={i}>{step}</li>
))}
</ul>
</div>
)}
{segment.readable.why.length > 0 && (
<div className={css({ mt: '0.5rem', fontSize: '0.75rem' })}>
<strong>Why:</strong>
<ul className={css({ pl: '1rem', mt: '0.25rem' })}>
{segment.readable.why.map((why, i) => (
<li key={i}>{why}</li>
))}
</ul>
</div>
)}
</div>
)
}
function StepDetail({ step }: { step: UnifiedInstructionSequence['steps'][0] }) {
return (
<div className={css({ p: '0.75rem', bg: 'gray.50', borderRadius: '6px', fontSize: '0.75rem' })}>
<div
className={css({ display: 'grid', gridTemplateColumns: 'auto 1fr', gap: '0.5rem 1rem' })}
>
<span className={css({ fontWeight: 'bold' })}>Term:</span>
<code>{step.mathematicalTerm}</code>
<span className={css({ fontWeight: 'bold' })}>Instruction:</span>
<span>{step.englishInstruction}</span>
<span className={css({ fontWeight: 'bold' })}>Expected value:</span>
<span>{step.expectedValue}</span>
<span className={css({ fontWeight: 'bold' })}>Segment:</span>
<span>{step.segmentId || '(none)'}</span>
<span className={css({ fontWeight: 'bold' })}>Valid:</span>
<span className={css({ color: step.isValid ? 'green.600' : 'red.600' })}>
{step.isValid ? '✓ Yes' : `✗ No: ${step.validationIssues?.join(', ')}`}
</span>
<span className={css({ fontWeight: 'bold' })}>Bead moves:</span>
<span>{step.beadMovements.length} movements</span>
</div>
{step.beadMovements.length > 0 && (
<div className={css({ mt: '0.5rem' })}>
<strong>Movements:</strong>
<ul className={css({ pl: '1rem', mt: '0.25rem' })}>
{step.beadMovements.map((m, i) => (
<li key={i}>
{m.beadType} @ place {m.placeValue}: {m.direction}
{m.position !== undefined && ` (pos ${m.position})`}
</li>
))}
</ul>
</div>
)}
</div>
)
}
// Custom test input
function CustomTestInput({ onSelect }: { onSelect: (start: number, target: number) => void }) {
const [start, setStart] = useState(0)
const [target, setTarget] = useState(17)
return (
<div className={css({ p: '1rem', bg: 'blue.50', borderRadius: '8px', mb: '1rem' })}>
<h3 className={css({ fontSize: '0.875rem', fontWeight: 'bold', mb: '0.75rem' })}>
Custom Test
</h3>
<div className={css({ display: 'flex', gap: '0.75rem', alignItems: 'flex-end' })}>
<label className={css({ flex: 1 })}>
<span className={css({ fontSize: '0.75rem', color: 'gray.600' })}>Start</span>
<input
type="number"
value={start}
onChange={(e) => setStart(Number(e.target.value))}
min={0}
max={9999}
className={css({
w: '100%',
p: '0.5rem',
borderRadius: '4px',
border: '1px solid',
borderColor: 'gray.300',
fontSize: '1rem',
fontFamily: 'monospace',
})}
/>
</label>
<span className={css({ fontSize: '1.25rem', pb: '0.5rem' })}></span>
<label className={css({ flex: 1 })}>
<span className={css({ fontSize: '0.75rem', color: 'gray.600' })}>Target</span>
<input
type="number"
value={target}
onChange={(e) => setTarget(Number(e.target.value))}
min={0}
max={9999}
className={css({
w: '100%',
p: '0.5rem',
borderRadius: '4px',
border: '1px solid',
borderColor: 'gray.300',
fontSize: '1rem',
fontFamily: 'monospace',
})}
/>
</label>
<button
type="button"
onClick={() => onSelect(start, target)}
className={css({
px: '1rem',
py: '0.5rem',
bg: 'blue.600',
color: 'white',
borderRadius: '6px',
fontWeight: 'bold',
cursor: 'pointer',
_hover: { bg: 'blue.700' },
})}
>
Test
</button>
</div>
</div>
)
}
// Main audit UI
function DecompositionAuditUI() {
const [selectedTest, setSelectedTest] = useState<{ start: number; target: number } | null>({
start: 3,
target: 17,
})
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
new Set(['Basic Addition (Direct)'])
)
const toggleCategory = useCallback((category: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev)
if (next.has(category)) {
next.delete(category)
} else {
next.add(category)
}
return next
})
}, [])
return (
<div
data-component="decomposition-audit"
className={css({
display: 'flex',
h: '100vh',
bg: 'gray.100',
})}
>
{/* Left Panel: Test Cases */}
<div
className={css({
w: '350px',
bg: 'white',
borderRight: '1px solid',
borderColor: 'gray.200',
overflow: 'auto',
p: '1rem',
})}
>
<h1 className={css({ fontSize: '1.25rem', fontWeight: 'bold', mb: '1rem' })}>
Decomposition Audit
</h1>
<CustomTestInput onSelect={(start, target) => setSelectedTest({ start, target })} />
{Object.entries(TEST_CATEGORIES).map(([category, tests]) => (
<div key={category} className={css({ mb: '1rem' })}>
<button
type="button"
onClick={() => toggleCategory(category)}
className={css({
w: '100%',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '0.5rem',
bg: 'gray.100',
borderRadius: '6px',
fontWeight: 'bold',
fontSize: '0.875rem',
cursor: 'pointer',
_hover: { bg: 'gray.200' },
})}
>
<span>{category}</span>
<span>{expandedCategories.has(category) ? '▼' : '▶'}</span>
</button>
{expandedCategories.has(category) && (
<div
className={css({
mt: '0.5rem',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
{tests.map((test) => (
<TestCaseDisplay
key={`${test.start}-${test.target}`}
testCase={test}
isSelected={
selectedTest?.start === test.start && selectedTest?.target === test.target
}
onClick={() => setSelectedTest({ start: test.start, target: test.target })}
/>
))}
</div>
)}
</div>
))}
</div>
{/* Right Panel: Details */}
<div className={css({ flex: 1, overflow: 'auto', p: '1.5rem' })}>
{selectedTest ? (
<DetailedView start={selectedTest.start} target={selectedTest.target} />
) : (
<div className={css({ textAlign: 'center', py: '4rem', color: 'gray.500' })}>
Select a test case to see details
</div>
)}
</div>
</div>
)
}
export const Audit: StoryObj = {
render: () => <DecompositionAuditUI />,
}

View File

@ -0,0 +1,506 @@
# Subtraction Implementation Plan for `unifiedStepGenerator.ts`
> **Related Documentation:** See [UNIFIED_STEP_GENERATOR_ARCHITECTURE.md](./UNIFIED_STEP_GENERATOR_ARCHITECTURE.md) for complete system documentation including data structures, integration points, and extension guides.
## Overview
This document outlines the implementation plan for adding subtraction support to the unified step generator. The goal is to generate pedagogically correct decomposition, English instructions, bead movements, and readable explanations for subtraction operations on the soroban.
## Current State
- **Addition**: Fully implemented with Direct, FiveComplement, TenComplement, and Cascade rules
- **Subtraction**: Throws `Error('Subtraction not implemented yet')` at line 705-708
- **Existing Infrastructure**:
- Skill definitions exist (`fiveComplementsSub`, `tenComplementsSub`)
- `analyzeSubtractionStepSkills()` in problemGenerator.ts works
- `generateInstructionFromTerm()` already handles negative terms
- `calculateBeadChanges()` works symmetrically for add/remove
---
## Soroban Subtraction Fundamentals
### The Three Cases
When subtracting digit `d` from current digit `a` at place P:
1. **Direct Subtraction** (`a ≥ d`): Remove beads directly
2. **Five's Complement Subtraction** (`a ≥ d` but earth beads insufficient): Use `-5 + (5-d)`
3. **Ten's Borrow** (`a < d`): Borrow from higher place, then subtract
### Key Difference from Addition
| Addition | Subtraction |
|----------|-------------|
| Carry **forward** (to higher place) | Borrow **from** higher place |
| `+10 - (10-d)` at current place | `-10` from next place, then work at current |
| Cascade when next place is **9** | Cascade when next place is **0** |
---
## Decision Tree for Subtraction
```
processSubtractionDigitAtPlace(digit, placeValue, currentDigitAtPlace, currentState):
a = currentDigitAtPlace (what the abacus shows at this place)
d = digit to subtract
L = earth beads active (0-4)
U = heaven bead active (0 or 1)
IF a >= d: ─────────────────────────────────────────────────────────
│ Can subtract without borrowing
├─► IF d <= 4:
│ │
│ ├─► IF L >= d:
│ │ → DIRECT: Remove d earth beads
│ │ Term: "-{d * 10^P}"
│ │
│ └─► ELSE (L < d, but a >= d means heaven is active):
│ → FIVE_COMPLEMENT_SUB: Deactivate heaven, add back (5-d)
│ Terms: "-{5 * 10^P}", "+{(5-d) * 10^P}"
│ Example: 7-4: have 5+2, remove 5, add 1 → 3
├─► IF d == 5:
│ → DIRECT: Deactivate heaven bead
│ Term: "-{5 * 10^P}"
└─► IF d >= 6:
→ DIRECT: Deactivate heaven + remove (d-5) earth beads
Terms: "-{5 * 10^P}", "-{(d-5) * 10^P}"
OR single term: "-{d * 10^P}"
ELSE (a < d):
│ Need to borrow from higher place
│ borrowAmount = d - a (how much we're short)
│ nextPlaceDigit = digit at (P+1)
├─► IF nextPlaceDigit > 0:
│ → SIMPLE_BORROW: Subtract 1 from next place, add 10 here
│ Step 1: "-{10^(P+1)}" (borrow)
│ Step 2: Process (a+10) - d at current place (may use complements)
└─► ELSE (nextPlaceDigit == 0):
→ CASCADE_BORROW: Find first non-zero place, borrow through
Operations:
1. Subtract 1 from first non-zero higher place
2. Set all intermediate zeros to 9
3. Add 10 to current place for subtraction
Example: 1000 - 1
- Subtract 1 from thousands: 1→0
- Hundreds 0→9, Tens 0→9, Ones 0→9+1=10
- Subtract 1 from ones: 10→9
Result: 999
```
---
## Implementation Plan
### Phase 1: Core Subtraction Functions
#### 1.1 Modify `generateDecompositionTerms()` (Lines 694-822)
Replace the error throw with subtraction handling:
```typescript
if (addend < 0) {
return generateSubtractionDecompositionTerms(startValue, targetValue, toState)
}
```
#### 1.2 New Function: `generateSubtractionDecompositionTerms()`
```typescript
function generateSubtractionDecompositionTerms(
startValue: number,
targetValue: number,
toState: (n: number) => AbacusState
): {
terms: string[]
segmentsPlan: SegmentDraft[]
decompositionSteps: DecompositionStep[]
}
```
**Algorithm:**
1. Calculate `subtrahend = startValue - targetValue` (positive number)
2. Process digit-by-digit from **right to left** (ones first)
- Unlike addition which goes left-to-right, subtraction must track borrows
3. For each digit, call `processSubtractionDigitAtPlace()`
4. Track `pendingBorrow` flag for cascade borrows
**Why right-to-left?** Borrowing propagates leftward, so we need to know if lower places needed to borrow before processing higher places.
Actually, let me reconsider... The current addition goes left-to-right. For consistency and because we're decomposing the subtrahend (the amount being subtracted), we could also go left-to-right BUT track when we'll need to borrow.
**Alternative approach:** Pre-scan to identify borrow points, then process left-to-right like addition.
#### 1.3 New Function: `processDirectSubtraction()`
Mirror of `processDirectAddition()`:
```typescript
function processDirectSubtraction(
digit: number,
placeValue: number,
currentState: AbacusState,
toState: (n: number) => AbacusState,
baseProvenance: TermProvenance
): { steps: DecompositionStep[]; newValue: number; newState: AbacusState }
```
**Cases:**
- `d <= 4, L >= d`: Remove earth beads directly
- `d <= 4, L < d, U == 1`: Five's complement (-5, +remainder)
- `d == 5, U == 1`: Remove heaven bead
- `d >= 6, U == 1`: Remove heaven + earth beads
#### 1.4 New Function: `processTensBorrow()`
```typescript
function processTensBorrow(
digit: number,
placeValue: number,
currentState: AbacusState,
toState: (n: number) => AbacusState,
baseProvenance: TermProvenance
): { steps: DecompositionStep[]; newValue: number; newState: AbacusState }
```
**Algorithm:**
1. Check next place digit
2. If > 0: Simple borrow
3. If == 0: Cascade borrow (find first non-zero)
#### 1.5 New Function: `generateCascadeBorrowSteps()`
```typescript
function generateCascadeBorrowSteps(
currentValue: number,
startPlace: number,
digitToSubtract: number,
baseProvenance: TermProvenance,
groupId: string
): DecompositionStep[]
```
---
### Phase 2: Segment Decision Classification
#### 2.1 New Pedagogical Rules
Add to `PedagogicalRule` type:
```typescript
export type PedagogicalRule =
| 'Direct'
| 'FiveComplement'
| 'TenComplement'
| 'Cascade'
// New for subtraction:
| 'DirectSub'
| 'FiveComplementSub'
| 'TenBorrow'
| 'CascadeBorrow'
```
**Or** reuse existing rules with context flag. The existing rules are:
- `Direct` - works for both add/sub
- `FiveComplement` - could work for both (context determines +5-n vs -5+n)
- `TenComplement` / `TenBorrow` - these are conceptually different
**Recommendation:** Keep existing rules, add operation context to segments.
#### 2.2 Update `determineSegmentDecisions()`
Add subtraction pattern detection:
```typescript
// Detect subtraction patterns
const hasNegativeFive = negatives.some(v => v === 5 * 10**place)
const hasPositiveAfterNegative = /* ... */
if (hasNegativeFive && positives.length > 0) {
// Five's complement subtraction: -5 + n
return [{ rule: 'FiveComplement', conditions: [...], explanation: [...] }]
}
```
---
### Phase 3: Readable Generation
#### 3.1 Update `generateSegmentReadable()`
Add subtraction-specific titles and summaries:
```typescript
// Detect if this is a subtraction segment
const isSubtraction = steps.some(s => s.operation.startsWith('-'))
if (isSubtraction) {
// Generate subtraction-specific readable content
title = rule === 'Direct'
? `Subtract ${digit} — ${placeName}`
: rule === 'FiveComplement'
? `Break down 5 — ${placeName}`
: rule === 'TenBorrow'
? hasCascade
? `Borrow (cascade) — ${placeName}`
: `Borrow 10 — ${placeName}`
: `Strategy — ${placeName}`
}
```
#### 3.2 Subtraction-specific summaries
```typescript
// Direct subtraction
summary = `Remove ${digit} from the ${placeName}. ${
digit <= 4
? `Take away ${digit} earth bead${digit > 1 ? 's' : ''}.`
: digit === 5
? 'Deactivate the heaven bead.'
: `Deactivate heaven bead and remove ${digit - 5} earth bead${digit > 6 ? 's' : ''}.`
}`
// Five's complement subtraction
summary = `Subtract ${digit} from the ${placeName}. Not enough earth beads to remove directly, so deactivate the heaven bead (5) and add back ${5 - digit} (that's 5 + ${5 - digit} = ${digit}).`
// Ten's borrow
summary = `Subtract ${digit} from the ${placeName}, but we only have ${currentDigit}. Borrow 10 from ${nextPlaceName}, giving us ${currentDigit + 10}. Now subtract ${digit} to get ${currentDigit + 10 - digit}.`
```
---
### Phase 4: Instruction Generation
#### 4.1 Verify `generateInstructionFromTerm()` Coverage
Current implementation (lines 1127-1175) already handles:
- `-1` to `-4`: "remove N earth beads"
- `-5`: "deactivate heaven bead"
- `-6` to `-9`: "deactivate heaven bead and remove N earth beads"
- `-10`, `-100`, etc.: "remove 1 from [place]"
**May need additions for:**
- Multi-digit subtraction terms
- Combined operations in single term
#### 4.2 Update `generateStepInstruction()`
Should work as-is since it uses bead movement directions ('activate'/'deactivate').
---
### Phase 5: Full Decomposition String
#### 5.1 Update `buildFullDecompositionWithPositions()`
Handle negative difference:
```typescript
if (difference < 0) {
// Format as: "startValue - |difference| = startValue - decomposition = targetValue"
// Example: "17 - 8 = 17 - (10 - 2) = 9"
leftSide = `${startValue} - ${Math.abs(difference)} = ${startValue} - `
}
```
---
## Test Cases
### Direct Subtraction
- `5 - 2 = 3` (remove 2 earth beads)
- `7 - 5 = 2` (deactivate heaven bead)
- `9 - 7 = 2` (deactivate heaven, remove 2 earth)
### Five's Complement Subtraction
- `7 - 4 = 3` (have 5+2, need to remove 4; -5+1)
- `6 - 3 = 3` (have 5+1, need to remove 3; -5+2)
### Simple Borrow
- `12 - 5 = 7` (borrow from tens)
- `23 - 8 = 15` (borrow from tens)
### Cascade Borrow
- `100 - 1 = 99` (cascade through two zeros)
- `1000 - 1 = 999` (cascade through three zeros)
- `1000 - 999 = 1` (massive cascade)
### Multi-digit Subtraction
- `45 - 23 = 22` (no borrowing needed)
- `52 - 27 = 25` (borrow in ones place)
- `503 - 247 = 256` (mixed borrowing)
---
## Risk Areas
1. **Right-to-left vs Left-to-right processing**
- Addition processes high-to-low (left-to-right)
- Subtraction traditionally processes low-to-high for borrowing
- Need to reconcile these approaches
2. **Provenance tracking**
- Subtrahend digits map to operations differently than addend
- Borrow operations don't map cleanly to single digits
3. **Cascade borrow complexity**
- Multiple intermediate steps
- Potential for very long decompositions
4. **UI consistency**
- Ensure subtraction segments display correctly
- Decomposition string formatting
---
## Implementation Order
1. **Phase 1.1-1.2**: Basic infrastructure (route to subtraction, skeleton functions)
2. **Phase 1.3**: Direct subtraction (simplest case)
3. **Phase 5**: Decomposition string for subtraction
4. **Test**: Verify direct subtraction works end-to-end
5. **Phase 1.4**: Simple borrow (no cascade)
6. **Test**: Verify simple borrow works
7. **Phase 1.5**: Cascade borrow
8. **Test**: Verify cascade works
9. **Phase 2-3**: Segment decisions and readables
10. **Phase 4**: Verify instructions
11. **Full integration testing**
---
## Design Decisions (Resolved)
### 1. Processing Order: Left-to-right (high to low place) ✅
**Decision:** Process subtraction left-to-right, same as addition.
**Rationale:** The right-to-left convention is only for pencil-paper arithmetic to avoid changing higher digits already written. On the abacus, we can always modify any column, so processing order doesn't matter mathematically. Left-to-right maintains consistency with addition and reads naturally.
### 2. Pedagogical Rules: Align with existing SkillSet ✅
**Decision:** Use skill IDs that match `src/types/tutorial.ts` SkillSet structure.
The existing skills are:
- **basic**: `directSubtraction`, `heavenBeadSubtraction`, `simpleCombinationsSub`
- **fiveComplementsSub**: `-4=-5+1`, `-3=-5+2`, `-2=-5+3`, `-1=-5+4`
- **tenComplementsSub**: `-9=+1-10`, `-8=+2-10`, `-7=+3-10`, etc.
The `PedagogicalRule` type can stay the same (`Direct`, `FiveComplement`, `TenComplement`, `Cascade`) with operation context determining the specific skill extraction.
### 3. Decomposition String Format: Addition of negative terms ✅
**Decision:** `17 - 8 = 17 + (-10 + 2) = 9`
**Rationale:** This format:
- Is consistent with how the system internally represents operations
- Uses signed terms that match bead movements directly
- Groups complement operations clearly in parentheses
### 4. Five's Complement Notation: Addition of negatives ✅
**Decision:** `(-5 + 1)` for five's complement subtraction
**Example:** `7 - 4 = 7 + (-5 + 1) = 3`
**Rationale:** This directly maps to bead movements:
- `-5` = deactivate heaven bead
- `+1` = activate earth bead
---
## Appendix: Worked Examples
### Example 1: 7 - 4 = 3 (Five's Complement Subtraction)
**Initial state:** 7 = heaven(5) + earth(2)
**Goal:** Subtract 4
**Skill:** `fiveComplementsSub['-4=-5+1']`
**Decision:**
- a = 7, d = 4
- a >= d ✓ (no borrow needed)
- d = 4, earth beads L = 2
- L < d, so can't remove 4 earth beads directly
- Heaven is active, so use five's complement
**Steps:**
1. Deactivate heaven bead: -5 (state: 2)
2. Add back (5-4)=1 earth bead: +1 (state: 3)
**Decomposition:** `7 - 4 = 7 + (-5 + 1) = 3`
### Example 2: 12 - 5 = 7 (Simple Ten's Borrow)
**Initial state:** 12 = tens(1) + earth(2)
**Goal:** Subtract 5
**Skill:** `tenComplementsSub['-5=+5-10']`
**Decision at ones place:**
- a = 2, d = 5
- a < d, need to borrow from tens
- tens = 1 ≠ 0, so simple borrow (no cascade)
**Steps:**
1. Borrow from tens: -10 (state: 2)
2. Add complement to ones: +5 (state: 7)
**Decomposition:** `12 - 5 = 12 + (-10 + 5) = 7`
### Example 3: 100 - 1 = 99 (Cascade Borrow)
**Initial state:** 100 = hundreds(1)
**Goal:** Subtract 1
**Skills:** `tenComplementsSub['-1=+9-10']` + Cascade
**Decision at ones place (processing left-to-right):**
- At hundreds: digit to subtract = 0, skip
- At tens: digit to subtract = 0, skip
- At ones: digit to subtract = 1
- a = 0, d = 1
- a < d, need to borrow
- tens = 0, cascade required
- Find first non-zero: hundreds = 1
**Steps:**
1. Borrow from hundreds: -100
2. Fill tens with 9: +90
3. Add 10 to ones (completing the borrow): +10
4. Subtract 1 from ones: -1
**Decomposition:** `100 - 1 = 100 + (-100 + 90 + 10 - 1) = 99`
Or grouped by operation:
`100 - 1 = 100 + (-100 + 90 + 9) = 99`
**Net check:** -100 + 90 + 10 - 1 = -1 ✓
### Example 4: 52 - 27 = 25 (Multi-digit with mixed operations)
**Initial state:** 52 = tens(5) + earth(2)
**Goal:** Subtract 27
**Processing left-to-right (tens first, then ones):**
**Tens place:** subtract 2
- a = 5, d = 2
- a >= d ✓, direct subtraction
- Terms: `-20`
**Ones place:** subtract 7
- a = 2, d = 7
- a < d, need to borrow from tens
- tens = 3 ≠ 0, simple borrow
- Skill: `tenComplementsSub['-7=+3-10']`
- Terms: `-10`, `+3`
**Full decomposition:** `52 - 27 = 52 + (-20) + (-10 + 3) = 25`

View File

@ -0,0 +1,536 @@
# Unified Step Generator Architecture
**A comprehensive guide to the pedagogical decomposition system for soroban arithmetic operations.**
## Overview
The Unified Step Generator is the core algorithm that powers all soroban arithmetic tutorials, practice hints, and coaching features in this application. It generates mathematically correct, pedagogically sound step-by-step breakdowns of arithmetic operations that are perfectly synchronized across:
- **Mathematical decomposition** (the equation breakdown)
- **English instructions** (what to do in words)
- **Bead movements** (which beads move where)
- **State transitions** (abacus state at each step)
- **Skill tracking** (which pedagogical skills are exercised)
## Quick Reference
**Main Entry Point:**
```typescript
import { generateUnifiedInstructionSequence } from '@/utils/unifiedStepGenerator'
const sequence = generateUnifiedInstructionSequence(startValue, targetValue)
// Returns: UnifiedInstructionSequence with all tutorial data
```
**Current Limitations:**
- ✅ Addition: Fully implemented
- ❌ Subtraction: Throws `Error('Subtraction not implemented yet')` at line 705-708
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ User Interface Layer │
│ ┌──────────────┐ ┌──────────────────┐ ┌─────────────────┐ │
│ │ TutorialPlayer│ │ DecompositionDisplay│ │ PracticeHelpPanel│ │
│ │ (step-by-step)│ │ (hover tooltips) │ │ (coach hints) │ │
│ └───────┬──────┘ └────────┬─────────┘ └────────┬────────┘ │
│ │ │ │ │
│ └──────────────────┼─────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ DecompositionContext │ │
│ │ - Manages highlighting state │ │
│ │ - Provides term ↔ column mappings │ │
│ │ - Handles hover/click coordination │ │
│ └───────────────────────────┬──────────────────────────────────────────┘ │
│ │ │
└──────────────────────────────┼───────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────┐
│ Core Algorithm Layer │
│ │
│ ┌──────────────────────────────────────────────────────────────────────┐ │
│ │ generateUnifiedInstructionSequence() │ │
│ │ │ │
│ │ Input: startValue, targetValue │ │
│ │ Output: UnifiedInstructionSequence │ │
│ │ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Step 1: generateDecompositionTerms() │ │ │
│ │ │ - Process digits left-to-right (highest place first) │ │ │
│ │ │ - Decision tree: a+d ≤ 9 → Direct/FiveComplement │ │ │
│ │ │ a+d > 9 → TenComplement/Cascade │ │ │
│ │ │ - Returns: terms[], segmentsPlan[], decompositionSteps[] │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Step 2: Build unified steps (for each term) │ │ │
│ │ │ - calculateStepResult() → newValue │ │ │
│ │ │ - calculateStepBeadMovements() → StepBeadHighlight[] │ │ │
│ │ │ - generateInstructionFromTerm() → English instruction │ │ │
│ │ │ - validateStepConsistency() → isValid, issues[] │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌─────────────────────────────────────────────────────────────────┐ │ │
│ │ │ Step 3: Build display structures │ │ │
│ │ │ - buildFullDecompositionWithPositions() → string + positions │ │ │
│ │ │ - buildSegmentsWithPositions() → PedagogicalSegment[] │ │ │
│ │ │ - generateSegmentReadable() → titles, summaries, chips │ │ │
│ │ │ - buildEquationAnchors() → digit highlighting positions │ │ │
│ │ └─────────────────────────────────────────────────────────────────┘ │ │
│ └──────────────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
---
## Core Data Structures
### UnifiedInstructionSequence
The main output of the system, containing everything needed for tutorials and help:
```typescript
interface UnifiedInstructionSequence {
// The full equation string: "3 + 14 = 3 + 10 + (5 - 1) = 17"
fullDecomposition: string
// Whether decomposition adds pedagogical value (vs redundant "5 = 5")
isMeaningfulDecomposition: boolean
// Individual steps with all coordinated data
steps: UnifiedStepData[]
// High-level "chapters" explaining the why
segments: PedagogicalSegment[]
// Start/end values and step count
startValue: number
targetValue: number
totalSteps: number
// For highlighting addend digits in UI
equationAnchors?: EquationAnchors
}
```
### UnifiedStepData
Each step contains perfectly synchronized information:
```typescript
interface UnifiedStepData {
stepIndex: number
// MATH: The term for this step
mathematicalTerm: string // e.g., "10", "-3", "5"
termPosition: { startIndex, endIndex } // Position in fullDecomposition
// ENGLISH: Human-readable instruction
englishInstruction: string // e.g., "add 1 to tens"
// STATE: Expected abacus state after this step
expectedValue: number
expectedState: AbacusState
// BEADS: Which beads move (for arrows/highlights)
beadMovements: StepBeadHighlight[]
// VALIDATION: Self-consistency check
isValid: boolean
validationIssues?: string[]
// TRACKING: Links to source
segmentId?: string
provenance?: TermProvenance
}
```
### PedagogicalSegment
Groups related steps into "chapters" with human-friendly explanations:
```typescript
interface PedagogicalSegment {
id: string // e.g., "place-1-digit-4"
place: number // Place value (0=ones, 1=tens, etc.)
digit: number // The digit being added
// Current abacus state at this place
a: number // Current digit showing
L: number // Earth beads active (0-4)
U: 0 | 1 // Heaven bead active?
// Pedagogical classification
goal: string // "Add 4 to tens with a carry"
plan: SegmentDecision[] // One or more rules applied
// Term/step mappings
expression: string // "(100 - 90 - 6)" for complements
stepIndices: number[] // Which steps belong here
termIndices: number[] // Which terms belong here
termRange: { startIndex, endIndex } // Position in fullDecomposition
// State snapshots
startValue: number
endValue: number
startState: AbacusState
endState: AbacusState
// Human-friendly content for tooltips
readable: SegmentReadable
}
```
### SegmentReadable
User-facing explanations generated for each segment:
```typescript
interface SegmentReadable {
title: string // "Make 10 — ones" or "Add 3 — tens"
subtitle?: string // "Using 10's friend"
chips: Array<{ label: string; value: string }> // Quick context
why: string[] // Bullet explanations
carryPath?: string // "Tens is 9 → hundreds +1; tens → 0"
stepsFriendly: string[] // Bead instructions for each step
showMath?: { lines: string[] } // Math explanation
summary: string // 1-2 sentence plain English
validation?: { ok: boolean; issues: string[] } // Dev self-check
}
```
### TermProvenance
Links each term back to its source in the original problem:
```typescript
interface TermProvenance {
rhs: number // The addend (e.g., 25)
rhsDigit: number // The specific digit (e.g., 2 for tens)
rhsPlace: number // Place value (1=tens, 0=ones)
rhsPlaceName: string // "tens"
rhsDigitIndex: number // Index in addend string (for UI)
rhsValue: number // digit × 10^place (e.g., 20)
groupId?: string // Same ID for complement groups
// For complement operations affecting multiple columns
termPlace?: number // Actual place this term affects
termPlaceName?: string
termValue?: number // Actual value (e.g., 100, -90)
}
```
---
## The Pedagogical Decision Tree
The core algorithm for choosing how to add a digit at a place:
```
processDigitAtPlace(digit, place, currentDigit, currentState):
a = currentDigit (what abacus shows at this place, 0-9)
d = digit to add (1-9)
L = earth beads active at place (0-4)
U = heaven bead active (0 or 1)
┌─────────────────────────────────────────────────────────────────────┐
│ CASE A: a + d ≤ 9 (fits without carry) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ IF d ≤ 4: │
│ ├─ IF L + d ≤ 4: │
│ │ → DIRECT: Add d earth beads │
│ │ Term: "d × 10^place" │
│ │ │
│ └─ ELSE (L + d > 4, but a + d ≤ 9 means heaven is off): │
│ → FIVE_COMPLEMENT: +5 - (5-d) │
│ Terms: "5 × 10^place", "-(5-d) × 10^place" │
│ Example: 3 + 4: have 3 earth, need 4 → +5 -1 → 7 │
│ │
│ IF d = 5: │
│ → DIRECT: Activate heaven bead │
│ Term: "5 × 10^place" │
│ │
│ IF d ≥ 6: │
│ → DIRECT: Activate heaven + add (d-5) earth beads │
│ Terms: "5 × 10^place", "(d-5) × 10^place" │
│ │
└─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐
│ CASE B: a + d > 9 (requires carry) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ nextPlaceDigit = digit at (place + 1) │
│ │
│ IF nextPlaceDigit ≠ 9: │
│ → SIMPLE TEN_COMPLEMENT: +10 - (10-d) │
│ Terms: "10^(place+1)", "-(10-d) × 10^place" │
│ Example: 7 + 5 → +10 -5 → 12 │
│ │
│ ELSE (nextPlaceDigit = 9): │
│ → CASCADE: Find highest non-9 place, add there, clear 9s │
│ Example: 99 + 5 → +100 -90 -5 → 104 │
│ Terms cascade through multiple places │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## The Four Pedagogical Rules
### 1. Direct
**When:** `a + d ≤ 9` and enough beads available
**What:** Simply add beads
**Example:** `3 + 2 = 5` (add 2 earth beads)
### 2. FiveComplement
**When:** `a + d ≤ 9` but not enough earth beads, heaven is inactive
**What:** `+d = +5 - (5-d)` — activate heaven, remove complement
**Example:** `3 + 4 = 7``+5 - 1` (activate heaven, remove 1 earth)
### 3. TenComplement
**When:** `a + d > 9` and next place is not 9
**What:** `+d = +10 - (10-d)` — add to next place, remove complement
**Example:** `7 + 5 = 12``+10 - 5` (add 1 to tens, remove 5 from ones)
### 4. Cascade
**When:** `a + d > 9` and next place is 9 (or chain of 9s)
**What:** Find first non-9 place, add there, clear all 9s
**Example:** `99 + 5 = 104``+100 - 90 - 5` (add 1 to hundreds, clear tens, subtract 5 from ones)
---
## Processing Order
**Addition processes digits LEFT TO RIGHT (highest place first).**
This is important because:
1. Carries propagate toward higher places
2. Processing high-to-low means we know the destination state before processing each digit
3. The decomposition string reads naturally (left-to-right like the original number)
```
Adding 45 to start value:
Step 1: Process "4" at tens place
Step 2: Process "5" at ones place
```
---
## Integration Points
### 1. DecompositionContext
**Location:** `src/contexts/DecompositionContext.tsx`
The React context that wraps components needing decomposition data:
```typescript
<DecompositionProvider startValue={0} targetValue={45}>
<DecompositionDisplay /> {/* Shows interactive equation */}
<AbacusWithHighlighting /> {/* Coordinated highlighting */}
</DecompositionProvider>
```
**Key features:**
- Memoized sequence generation
- Term ↔ column bidirectional mapping
- Highlighting state management
- Event coordination (hover, click)
### 2. DecompositionDisplay
**Location:** `src/components/decomposition/DecompositionDisplay.tsx`
Renders the interactive equation with:
- Hoverable terms that show tooltips
- Grouped segments (parenthesized complements)
- Current step highlighting
- Multi-line overflow handling
### 3. ReasonTooltip
**Location:** `src/components/decomposition/ReasonTooltip.tsx`
Rich tooltips showing:
- Rule name and emoji (✨ Direct, 🤝 Five's Friend, 🔟 Ten's Friend, 🌊 Cascade)
- Summary explanation
- Context chips (source digit, rod shows)
- Expandable details (math, bead steps, carry path)
- Provenance information
### 4. Practice Help System
**Location:** `src/hooks/usePracticeHelp.ts`
Progressive help levels using the unified sequence:
- **L0:** No help
- **L1:** Coach hint (from `segment.readable.summary`)
- **L2:** Decomposition display
- **L3:** Bead arrows (from `step.beadMovements`)
### 5. Skill Extraction
**Location:** `src/utils/skillExtraction.ts`
Maps pedagogical segments to mastery tracking:
- `Direct``basic.directAddition`, `basic.heavenBead`, `basic.simpleCombinations`
- `FiveComplement``fiveComplements.4=5-1`, etc.
- `TenComplement``tenComplements.9=10-1`, etc.
- `Cascade` → Same as TenComplement (underlying skill)
---
## Test Coverage
**292 snapshot tests** protect the algorithm across:
- 41 Direct entry cases
- 25 Five-complement cases
- 28 Ten-complement cases
- 25 Cascading cases
- 18 Mixed operation cases
- 25 Edge cases
- 15 Large number operations
- 50 Systematic coverage tests
- 8 Stress test cases
- 21 Regression prevention cases
See `src/utils/__tests__/SNAPSHOT_TEST_SUMMARY.md` for details.
---
## Validation System
Each step is validated for self-consistency:
```typescript
validateStepConsistency(term, instruction, startValue, expectedValue, beadMovements, toState)
```
Checks:
1. Bead movements produce the expected state
2. Earth bead counts stay in valid range (0-4)
3. Heaven bead state is boolean
4. Simulated state matches expected state
5. Numeric value matches
Validation results are stored in `step.isValid` and `step.validationIssues`.
---
## Known Limitations
### Subtraction Not Implemented
The system currently only handles addition. Subtraction throws an error:
```typescript
if (addend < 0) {
throw new Error('Subtraction not implemented yet')
}
```
See `SUBTRACTION_IMPLEMENTATION_PLAN.md` for the planned implementation.
### Processing Order Fixed
The left-to-right (high-to-low place) processing order is hardcoded. This works well for addition but may need reconsideration for subtraction (where borrowing propagates differently).
---
## File Map
```
src/utils/
├── unifiedStepGenerator.ts # Core algorithm (1764 lines)
├── abacusInstructionGenerator.ts # Re-exports + legacy helpers
├── skillExtraction.ts # Maps rules to skill IDs
├── UNIFIED_STEP_GENERATOR_ARCHITECTURE.md # This document
├── SUBTRACTION_IMPLEMENTATION_PLAN.md # Subtraction design
└── __tests__/
├── pedagogicalSnapshot.test.ts # 292 snapshot tests
├── unifiedStepGenerator.correctness.test.ts
├── provenance.test.ts
└── SNAPSHOT_TEST_SUMMARY.md
src/contexts/
└── DecompositionContext.tsx # React context wrapper
src/components/decomposition/
├── DecompositionDisplay.tsx # Interactive equation display
├── ReasonTooltip.tsx # Pedagogical tooltips
├── README.md # Component usage guide
├── decomposition.css
└── reason-tooltip.css
src/hooks/
└── usePracticeHelp.ts # Progressive help hook
src/components/practice/
└── coachHintGenerator.ts # Simple hint extraction
```
---
## Extension Guide
### Adding a New Pedagogical Rule
1. Add to `PedagogicalRule` type:
```typescript
export type PedagogicalRule = 'Direct' | 'FiveComplement' | 'TenComplement' | 'Cascade' | 'NewRule'
```
2. Add decision function in `unifiedStepGenerator.ts`:
```typescript
function decisionForNewRule(...): SegmentDecision[] { ... }
```
3. Update `determineSegmentDecisions()` to detect and return the new rule
4. Update `generateSegmentReadable()` with title/summary for the rule
5. Update `ReasonTooltip` with emoji and description
6. Update `skillExtraction.ts` to map to skill IDs
7. Add snapshot tests
### Adding Multi-Step Animations
The `beadMovements` array on each step is already ordered:
1. Higher place first
2. Heaven beads before earth
3. Activations before deactivations
Use `step.beadMovements[].order` for animation sequencing.
---
## Glossary
| Term | Definition |
|------|------------|
| **Place** | Position in number (0=ones, 1=tens, 2=hundreds) |
| **Heaven bead** | The single bead above the reckoning bar (value: 5) |
| **Earth beads** | The four beads below the reckoning bar (value: 1 each) |
| **Complement** | The number that adds to make 5 or 10 |
| **Cascade** | Chain reaction through consecutive 9s |
| **Provenance** | Tracking where a term came from in the original problem |
| **Segment** | Group of related terms forming one pedagogical "chapter" |
---
*Last updated: December 2024*

View File

@ -60,94 +60,158 @@ function extractSkillsFromSegment(segment: PedagogicalSegment): ExtractedSkill[]
const primaryRule = plan[0]?.rule
if (!primaryRule) return skills
// Detect subtraction by checking segment ID suffix or step operations
// Subtraction segments have IDs ending in '-sub'
const isSubtraction = segment.id.endsWith('-sub')
switch (primaryRule) {
case 'Direct':
// Direct addition/subtraction - check what type
if (digit <= 4) {
skills.push({
skillId: 'basic.directAddition',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
} else if (digit === 5) {
skills.push({
skillId: 'basic.heavenBead',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
if (isSubtraction) {
if (digit <= 4) {
skills.push({
skillId: 'basic.directSubtraction',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
} else if (digit === 5) {
skills.push({
skillId: 'basic.heavenBeadSubtraction',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
} else {
// 6-9 without complements means simple combinations
skills.push({
skillId: 'basic.simpleCombinationsSub',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
}
} else {
// 6-9 without complements means simple combinations
skills.push({
skillId: 'basic.simpleCombinations',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
if (digit <= 4) {
skills.push({
skillId: 'basic.directAddition',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
} else if (digit === 5) {
skills.push({
skillId: 'basic.heavenBead',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
} else {
// 6-9 without complements means simple combinations
skills.push({
skillId: 'basic.simpleCombinations',
rule: 'Direct',
place,
digit,
segmentId: segment.id,
})
}
}
break
case 'FiveComplement': {
// Five's complement: +d = +5 - (5-d)
// The skill key format is "d=5-(5-d)" which simplifies to the digit pattern
const fiveComplementKey = getFiveComplementKey(digit)
if (fiveComplementKey) {
skills.push({
skillId: `fiveComplements.${fiveComplementKey}`,
rule: 'FiveComplement',
place,
digit,
segmentId: segment.id,
})
if (isSubtraction) {
// Five's complement subtraction: -d = -5 + (5-d)
const fiveComplementSubKey = getFiveComplementSubKey(digit)
if (fiveComplementSubKey) {
skills.push({
skillId: `fiveComplementsSub.${fiveComplementSubKey}`,
rule: 'FiveComplement',
place,
digit,
segmentId: segment.id,
})
}
} else {
// Five's complement addition: +d = +5 - (5-d)
const fiveComplementKey = getFiveComplementKey(digit)
if (fiveComplementKey) {
skills.push({
skillId: `fiveComplements.${fiveComplementKey}`,
rule: 'FiveComplement',
place,
digit,
segmentId: segment.id,
})
}
}
break
}
case 'TenComplement': {
// Ten's complement: +d = +10 - (10-d)
const tenComplementKey = getTenComplementKey(digit)
if (tenComplementKey) {
skills.push({
skillId: `tenComplements.${tenComplementKey}`,
rule: 'TenComplement',
place,
digit,
segmentId: segment.id,
})
if (isSubtraction) {
// Ten's complement subtraction (borrow): -d = +(10-d) - 10
const tenComplementSubKey = getTenComplementSubKey(digit)
if (tenComplementSubKey) {
skills.push({
skillId: `tenComplementsSub.${tenComplementSubKey}`,
rule: 'TenComplement',
place,
digit,
segmentId: segment.id,
})
}
} else {
// Ten's complement addition: +d = +10 - (10-d)
const tenComplementKey = getTenComplementKey(digit)
if (tenComplementKey) {
skills.push({
skillId: `tenComplements.${tenComplementKey}`,
rule: 'TenComplement',
place,
digit,
segmentId: segment.id,
})
}
}
break
}
case 'Cascade': {
// Cascade is triggered by TenComplement with consecutive 9s
// The underlying skill is still TenComplement
const cascadeKey = getTenComplementKey(digit)
if (cascadeKey) {
skills.push({
skillId: `tenComplements.${cascadeKey}`,
rule: 'Cascade',
place,
digit,
segmentId: segment.id,
})
// Cascade is triggered by TenComplement with consecutive 9s/0s
// The underlying skill is still TenComplement (addition) or TenComplementSub (subtraction)
if (isSubtraction) {
const cascadeSubKey = getTenComplementSubKey(digit)
if (cascadeSubKey) {
skills.push({
skillId: `tenComplementsSub.${cascadeSubKey}`,
rule: 'Cascade',
place,
digit,
segmentId: segment.id,
})
}
} else {
const cascadeKey = getTenComplementKey(digit)
if (cascadeKey) {
skills.push({
skillId: `tenComplements.${cascadeKey}`,
rule: 'Cascade',
place,
digit,
segmentId: segment.id,
})
}
}
break
}
}
// Check for additional rules in the plan (e.g., TenComplement + Cascade)
if (plan.length > 1) {
for (let i = 1; i < plan.length; i++) {
const additionalRule = plan[i]
if (additionalRule.rule === 'Cascade') {
}
}
}
return skills
}
@ -186,6 +250,41 @@ function getTenComplementKey(digit: number): string | null {
return mapping[digit] ?? null
}
/**
* Map a digit to its five's complement subtraction skill key
* Five's complement subtraction: -d = -5 + (5-d)
* -4 = -5 + 1, -3 = -5 + 2, -2 = -5 + 3, -1 = -5 + 4
*/
function getFiveComplementSubKey(digit: number): string | null {
const mapping: Record<number, string> = {
4: '-4=-5+1',
3: '-3=-5+2',
2: '-2=-5+3',
1: '-1=-5+4',
}
return mapping[digit] ?? null
}
/**
* Map a digit to its ten's complement subtraction skill key
* Ten's complement subtraction (borrow): -d = +(10-d) - 10
* -9 = +1 - 10, -8 = +2 - 10, etc.
*/
function getTenComplementSubKey(digit: number): string | null {
const mapping: Record<number, string> = {
9: '-9=+1-10',
8: '-8=+2-10',
7: '-7=+3-10',
6: '-6=+4-10',
5: '-5=+5-10',
4: '-4=+6-10',
3: '-3=+7-10',
2: '-2=+8-10',
1: '-1=+9-10',
}
return mapping[digit] ?? null
}
/**
* Get unique skill IDs from an instruction sequence
* Useful for tracking which skills were exercised in a problem

File diff suppressed because it is too large Load Diff