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:
parent
4f7a9d76cd
commit
e5c697b7a8
|
|
@ -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 />,
|
||||
}
|
||||
|
|
@ -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`
|
||||
|
|
@ -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*
|
||||
|
|
@ -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
Loading…
Reference in New Issue