diff --git a/apps/web/src/components/decomposition/DecompositionAudit.stories.tsx b/apps/web/src/components/decomposition/DecompositionAudit.stories.tsx new file mode 100644 index 00000000..9098b295 --- /dev/null +++ b/apps/web/src/components/decomposition/DecompositionAudit.stories.tsx @@ -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 = { + '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 ( + + ) +} + +// 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 ( +
+
+
🚧
+

+ {operation} Not Yet Implemented +

+

+ {start} → {target} ({difference >= 0 ? '+' : ''} + {difference}) +

+

+ {error || 'The decomposition system does not yet support this operation.'} +

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+
+

+ {start} → {target} +

+

+ {operation}: {difference >= 0 ? '+' : ''} + {difference} +

+
+
+
+ {sequence.steps.length} steps, {sequence.segments.length} segments +
+
+ {sequence.isMeaningfulDecomposition ? '✓ Meaningful' : '○ Trivial'} +
+
+
+
+ + {/* Decomposition Display */} +
+

+ Decomposition +

+ + + +
+ Raw: {sequence.fullDecomposition} +
+
+ + {/* Interactive Abacus */} +
+

+ Interactive Abacus +

+
+ +
+
+ + {/* Segments */} +
+

+ Pedagogical Segments ({sequence.segments.length}) +

+
+ {sequence.segments.map((segment, i) => ( + + ))} +
+
+ + {/* Steps */} +
+

+ Steps ({sequence.steps.length}) +

+
+ {sequence.steps.map((step, i) => ( + + ))} +
+ {sequence.steps[currentStep] && } +
+ + {/* Debug JSON */} +
+ + Raw JSON (click to expand) + +
+          {JSON.stringify(sequence, null, 2)}
+        
+
+
+ ) +} + +function SegmentCard({ segment, index }: { segment: PedagogicalSegment; index: number }) { + const rule = segment.plan[0]?.rule || 'Direct' + + return ( +
+
+
+
+ {getRuleEmoji(rule)} {segment.readable.title} +
+ {segment.readable.subtitle && ( +
+ {segment.readable.subtitle} +
+ )} +
+
+ {segment.id} +
+
+ +
+ Summary: {segment.readable.summary} +
+ +
+ Goal: {segment.goal} +
+ +
+ Expression: {segment.expression} +
+ +
+ Value change: {segment.startValue} → {segment.endValue} +
+ + {segment.readable.stepsFriendly.length > 0 && ( +
+ Bead moves: +
    + {segment.readable.stepsFriendly.map((step, i) => ( +
  • {step}
  • + ))} +
+
+ )} + + {segment.readable.why.length > 0 && ( +
+ Why: +
    + {segment.readable.why.map((why, i) => ( +
  • {why}
  • + ))} +
+
+ )} +
+ ) +} + +function StepDetail({ step }: { step: UnifiedInstructionSequence['steps'][0] }) { + return ( +
+
+ Term: + {step.mathematicalTerm} + + Instruction: + {step.englishInstruction} + + Expected value: + {step.expectedValue} + + Segment: + {step.segmentId || '(none)'} + + Valid: + + {step.isValid ? '✓ Yes' : `✗ No: ${step.validationIssues?.join(', ')}`} + + + Bead moves: + {step.beadMovements.length} movements +
+ + {step.beadMovements.length > 0 && ( +
+ Movements: +
    + {step.beadMovements.map((m, i) => ( +
  • + {m.beadType} @ place {m.placeValue}: {m.direction} + {m.position !== undefined && ` (pos ${m.position})`} +
  • + ))} +
+
+ )} +
+ ) +} + +// Custom test input +function CustomTestInput({ onSelect }: { onSelect: (start: number, target: number) => void }) { + const [start, setStart] = useState(0) + const [target, setTarget] = useState(17) + + return ( +
+

+ Custom Test +

+
+ + + + +
+
+ ) +} + +// Main audit UI +function DecompositionAuditUI() { + const [selectedTest, setSelectedTest] = useState<{ start: number; target: number } | null>({ + start: 3, + target: 17, + }) + const [expandedCategories, setExpandedCategories] = useState>( + 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 ( +
+ {/* Left Panel: Test Cases */} +
+

+ Decomposition Audit +

+ + setSelectedTest({ start, target })} /> + + {Object.entries(TEST_CATEGORIES).map(([category, tests]) => ( +
+ + + {expandedCategories.has(category) && ( +
+ {tests.map((test) => ( + setSelectedTest({ start: test.start, target: test.target })} + /> + ))} +
+ )} +
+ ))} +
+ + {/* Right Panel: Details */} +
+ {selectedTest ? ( + + ) : ( +
+ Select a test case to see details +
+ )} +
+
+ ) +} + +export const Audit: StoryObj = { + render: () => , +} diff --git a/apps/web/src/utils/SUBTRACTION_IMPLEMENTATION_PLAN.md b/apps/web/src/utils/SUBTRACTION_IMPLEMENTATION_PLAN.md new file mode 100644 index 00000000..c1654af5 --- /dev/null +++ b/apps/web/src/utils/SUBTRACTION_IMPLEMENTATION_PLAN.md @@ -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` diff --git a/apps/web/src/utils/UNIFIED_STEP_GENERATOR_ARCHITECTURE.md b/apps/web/src/utils/UNIFIED_STEP_GENERATOR_ARCHITECTURE.md new file mode 100644 index 00000000..8f0d8096 --- /dev/null +++ b/apps/web/src/utils/UNIFIED_STEP_GENERATOR_ARCHITECTURE.md @@ -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 + + {/* Shows interactive equation */} + {/* Coordinated highlighting */} + +``` + +**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* diff --git a/apps/web/src/utils/skillExtraction.ts b/apps/web/src/utils/skillExtraction.ts index 599f0493..9aff0c1c 100644 --- a/apps/web/src/utils/skillExtraction.ts +++ b/apps/web/src/utils/skillExtraction.ts @@ -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 = { + 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 = { + 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 diff --git a/apps/web/src/utils/unifiedStepGenerator.ts b/apps/web/src/utils/unifiedStepGenerator.ts index 420b90a9..35b6110b 100644 --- a/apps/web/src/utils/unifiedStepGenerator.ts +++ b/apps/web/src/utils/unifiedStepGenerator.ts @@ -277,58 +277,101 @@ function generateSegmentReadable( // Pull first available provenance from this segment's steps const provenance = stepIndices.map((i) => steps[i]?.provenance).find(Boolean) + // Detect if this is a subtraction segment (negative rhs in provenance) + const isSubtraction = provenance?.rhs !== undefined && provenance.rhs < 0 + // Helper numbers const s5 = 5 - digit const s10 = 10 - digit const nextPlaceName = getPlaceName(place + 1) // Title is short + kid-friendly - const title = - rule === 'Direct' - ? `Add ${digit} — ${placeName}` - : rule === 'FiveComplement' - ? `Make 5 — ${placeName}` - : rule === 'TenComplement' - ? hasCascade - ? `Make 10 (carry) — ${placeName}` - : `Make 10 — ${placeName}` - : rule === 'Cascade' - ? `Carry ripple — ${placeName}` - : `Strategy — ${placeName}` + let title: string + if (isSubtraction) { + title = + rule === 'Direct' + ? `Subtract ${digit} — ${placeName}` + : rule === 'FiveComplement' + ? `Break 5 — ${placeName}` + : rule === 'TenComplement' + ? hasCascade + ? `Borrow (cascade) — ${placeName}` + : `Borrow 10 — ${placeName}` + : rule === 'Cascade' + ? `Borrow ripple — ${placeName}` + : `Strategy — ${placeName}` + } else { + title = + rule === 'Direct' + ? `Add ${digit} — ${placeName}` + : rule === 'FiveComplement' + ? `Make 5 — ${placeName}` + : rule === 'TenComplement' + ? hasCascade + ? `Make 10 (carry) — ${placeName}` + : `Make 10 — ${placeName}` + : rule === 'Cascade' + ? `Carry ripple — ${placeName}` + : `Strategy — ${placeName}` + } // Minimal chips (0–2), provenance first if present const chips: Array<{ label: string; value: string }> = [] if (provenance) { chips.push({ - label: 'From addend', + label: isSubtraction ? 'From subtrahend' : 'From addend', value: `${provenance.rhsDigit} ${provenance.rhsPlaceName}`, }) } chips.push({ label: 'Rod shows', value: `${currentDigit}` }) - // Carry path (kept terse) + // Carry/borrow path (kept terse) let carryPath: string | undefined if (rule === 'TenComplement') { - if (hasCascade) { - const nextPlace = place + 1 - const nextVal = - (startState[nextPlace]?.heavenActive ? 5 : 0) + (startState[nextPlace]?.earthActive || 0) - if (nextVal === 9) { - // Find highest non‑9 to name the landing place - const maxPlace = Math.max(0, ...Object.keys(startState).map(Number)) + 2 - let k = nextPlace + 1 - for (; k <= maxPlace; k++) { - const v = (startState[k]?.heavenActive ? 5 : 0) + (startState[k]?.earthActive || 0) - if (v !== 9) break + if (isSubtraction) { + // Borrow path for subtraction + if (hasCascade) { + const nextPlace = place + 1 + const nextVal = + (startState[nextPlace]?.heavenActive ? 5 : 0) + (startState[nextPlace]?.earthActive || 0) + if (nextVal === 0) { + // Find highest non-0 to name the borrowing source + const maxPlace = Math.max(0, ...Object.keys(startState).map(Number)) + 2 + let k = nextPlace + 1 + for (; k <= maxPlace; k++) { + const v = (startState[k]?.heavenActive ? 5 : 0) + (startState[k]?.earthActive || 0) + if (v !== 0) break + } + const toName = getPlaceName(k) + carryPath = `${getPlaceName(nextPlace)} is 0 ⇒ borrow from ${toName}; fill 9s` + } else { + carryPath = `Borrow from ${nextPlaceName}` + } + } else { + carryPath = `Borrow from ${nextPlaceName}` + } + } else { + // Carry path for addition + if (hasCascade) { + const nextPlace = place + 1 + const nextVal = + (startState[nextPlace]?.heavenActive ? 5 : 0) + (startState[nextPlace]?.earthActive || 0) + if (nextVal === 9) { + const maxPlace = Math.max(0, ...Object.keys(startState).map(Number)) + 2 + let k = nextPlace + 1 + for (; k <= maxPlace; k++) { + const v = (startState[k]?.heavenActive ? 5 : 0) + (startState[k]?.earthActive || 0) + if (v !== 9) break + } + const landingIsNewHighest = k > maxPlace + const toName = landingIsNewHighest ? 'next higher place' : getPlaceName(k) + carryPath = `${getPlaceName(nextPlace)} is 9 ⇒ ${toName} +1; clear 9s` + } else { + carryPath = `${nextPlaceName} +1` } - const landingIsNewHighest = k > maxPlace - const toName = landingIsNewHighest ? 'next higher place' : getPlaceName(k) - carryPath = `${getPlaceName(nextPlace)} is 9 ⇒ ${toName} +1; clear 9s` } else { carryPath = `${nextPlaceName} +1` } - } else { - carryPath = `${nextPlaceName} +1` } } @@ -339,57 +382,118 @@ function generateSegmentReadable( // Semantic, 1–2 sentence summary let summary = '' - if (rule === 'Direct') { - if (digit <= 4) { - summary = `Add ${digit} to the ${placeName}. It fits here, so just move ${digit} lower bead${digit > 1 ? 's' : ''}.` + if (isSubtraction) { + // Subtraction summaries + if (rule === 'Direct') { + if (digit <= 4) { + summary = `Subtract ${digit} from the ${placeName}. You have enough beads, so just remove ${digit} lower bead${digit > 1 ? 's' : ''}.` + } else if (digit === 5) { + summary = `Subtract ${digit} from the ${placeName}. Just lower the heaven bead.` + } else { + const rest = digit - 5 + summary = `Subtract ${digit} from the ${placeName}: lower the heaven bead (−5) and remove ${rest} earth bead${rest > 1 ? 's' : ''}.` + } + } else if (rule === 'FiveComplement') { + summary = `Subtract ${digit} from the ${placeName}, but there aren't enough lower beads. Use 5's friend: lower the heaven bead (−5) and add ${s5} — that's −5 + ${s5}.` + } else if (rule === 'TenComplement') { + if (hasCascade) { + summary = `Subtract ${digit} from the ${placeName}, but you only have ${currentDigit}. Borrow from a higher place; because the next rod is 0, the borrow cascades, then add ${s10} here (that's −10 + ${s10}).` + } else { + summary = `Subtract ${digit} from the ${placeName}, but you only have ${currentDigit}. Borrow 10 from ${nextPlaceName} and add ${s10} here (that's −10 + ${s10}).` + } } else { - const rest = digit - 5 - summary = `Add ${digit} to the ${placeName} using the heaven bead: +5${rest ? ` + ${rest}` : ''}. No carry needed.` - } - } else if (rule === 'FiveComplement') { - summary = `Add ${digit} to the ${placeName}, but there isn't room for that many lower beads. Use 5's friend: press the heaven bead (5) and lift ${s5} — that's +5 − ${s5}.` - } else if (rule === 'TenComplement') { - if (hasCascade) { - summary = `Add ${digit} to the ${placeName} to make 10. Carry to ${nextPlaceName}; because the next rod is 9, the carry ripples up, then take ${s10} here (that's +10 − ${s10}).` - } else { - summary = `Add ${digit} to the ${placeName} to make 10: carry 1 to ${nextPlaceName} and take ${s10} here (that's +10 − ${s10}).` + summary = `Apply the strategy on the ${placeName}.` } } else { - summary = `Apply the strategy on the ${placeName}.` + // Addition summaries (existing) + if (rule === 'Direct') { + if (digit <= 4) { + summary = `Add ${digit} to the ${placeName}. It fits here, so just move ${digit} lower bead${digit > 1 ? 's' : ''}.` + } else { + const rest = digit - 5 + summary = `Add ${digit} to the ${placeName} using the heaven bead: +5${rest ? ` + ${rest}` : ''}. No carry needed.` + } + } else if (rule === 'FiveComplement') { + summary = `Add ${digit} to the ${placeName}, but there isn't room for that many lower beads. Use 5's friend: press the heaven bead (5) and lift ${s5} — that's +5 − ${s5}.` + } else if (rule === 'TenComplement') { + if (hasCascade) { + summary = `Add ${digit} to the ${placeName} to make 10. Carry to ${nextPlaceName}; because the next rod is 9, the carry ripples up, then take ${s10} here (that's +10 − ${s10}).` + } else { + summary = `Add ${digit} to the ${placeName} to make 10: carry 1 to ${nextPlaceName} and take ${s10} here (that's +10 − ${s10}).` + } + } else { + summary = `Apply the strategy on the ${placeName}.` + } } // Short subtitle (optional, reused from your rule badges) - const subtitle = - rule === 'Direct' - ? digit <= 4 - ? 'Simple move' - : 'Heaven bead helps' - : rule === 'FiveComplement' - ? "Using 5's friend" - : rule === 'TenComplement' - ? "Using 10's friend" - : undefined + let subtitle: string | undefined + if (isSubtraction) { + subtitle = + rule === 'Direct' + ? digit <= 4 + ? 'Simple removal' + : 'Heaven bead helps' + : rule === 'FiveComplement' + ? "Using 5's friend" + : rule === 'TenComplement' + ? 'Borrowing' + : undefined + } else { + subtitle = + rule === 'Direct' + ? digit <= 4 + ? 'Simple move' + : 'Heaven bead helps' + : rule === 'FiveComplement' + ? "Using 5's friend" + : rule === 'TenComplement' + ? "Using 10's friend" + : undefined + } // Tiny, dev-only validation of the summary against the selected rule const issues: string[] = [] const guards = plan.flatMap((p) => p.conditions) - if (rule === 'FiveComplement' && !guards.some((g) => /L\s*\+\s*d.*>\s*4/.test(g))) { - issues.push('FiveComplement summary emitted but guard L+d>4 not present') - } - if (rule === 'TenComplement' && !guards.some((g) => /a\s*\+\s*d.*(≥|>=)\s*10/.test(g))) { - issues.push('TenComplement summary emitted but guard a+d≥10 not present') - } - if (rule === 'Direct' && !guards.some((g) => /a\s*\+\s*d.*(≤|<=)\s*9/.test(g))) { - issues.push('Direct summary emitted but guard a+d≤9 not present') + if (isSubtraction) { + // Subtraction validation + if (rule === 'FiveComplement' && !guards.some((g) => /L\s*<\s*d/.test(g))) { + issues.push('FiveComplement (sub) summary emitted but guard L < d not present') + } + if (rule === 'TenComplement' && !guards.some((g) => /a.*<.*d/.test(g))) { + issues.push('TenComplement (sub) summary emitted but guard a < d not present') + } + if (rule === 'Direct' && !guards.some((g) => /a.*>=.*d/.test(g))) { + issues.push('Direct (sub) summary emitted but guard a >= d not present') + } + } else { + // Addition validation + if (rule === 'FiveComplement' && !guards.some((g) => /L\s*\+\s*d.*>\s*4/.test(g))) { + issues.push('FiveComplement summary emitted but guard L+d>4 not present') + } + if (rule === 'TenComplement' && !guards.some((g) => /a\s*\+\s*d.*(≥|>=)\s*10/.test(g))) { + issues.push('TenComplement summary emitted but guard a+d≥10 not present') + } + if (rule === 'Direct' && !guards.some((g) => /a\s*\+\s*d.*(≤|<=)\s*9/.test(g))) { + issues.push('Direct summary emitted but guard a+d≤9 not present') + } } const validation = { ok: issues.length === 0, issues } // Minimal "show the math" for students who want it const showMathLines: string[] = [] - if (rule === 'FiveComplement') { - showMathLines.push(`+5 − ${s5} = +${digit} (at this rod)`) - } else if (rule === 'TenComplement') { - showMathLines.push(`+10 − ${s10} = +${digit} (with a carry)`) + if (isSubtraction) { + if (rule === 'FiveComplement') { + showMathLines.push(`−5 + ${s5} = −${digit} (at this rod)`) + } else if (rule === 'TenComplement') { + showMathLines.push(`−10 + ${s10} = −${digit} (with a borrow)`) + } + } else { + if (rule === 'FiveComplement') { + showMathLines.push(`+5 − ${s5} = +${digit} (at this rod)`) + } else if (rule === 'TenComplement') { + showMathLines.push(`+10 − ${s10} = +${digit} (with a carry)`) + } } return { @@ -703,8 +807,7 @@ function generateDecompositionTerms( const addend = targetValue - startValue if (addend === 0) return { terms: [], segmentsPlan: [], decompositionSteps: [] } if (addend < 0) { - // TODO: Handle subtraction in separate sprint - throw new Error('Subtraction not implemented yet') + return generateSubtractionDecompositionTerms(startValue, targetValue, toState) } // Convert to abacus state representation with correct dimensions @@ -1114,6 +1217,543 @@ function generateCascadeComplementSteps( return steps } +// ============================================================================= +// SUBTRACTION IMPLEMENTATION +// ============================================================================= + +/** + * Generate decomposition terms for subtraction operations. + * Process subtrahend left-to-right (highest to lowest place), same as addition. + */ +function generateSubtractionDecompositionTerms( + startValue: number, + targetValue: number, + toState: (n: number) => AbacusState +): { + terms: string[] + segmentsPlan: SegmentDraft[] + decompositionSteps: DecompositionStep[] +} { + const subtrahend = startValue - targetValue // positive number to subtract + if (subtrahend <= 0) return { terms: [], segmentsPlan: [], decompositionSteps: [] } + + let currentState = toState(startValue) + let currentValue = startValue + const steps: DecompositionStep[] = [] + const segmentsPlan: SegmentDraft[] = [] + + // Process subtrahend digit by digit from left to right (highest to lowest place) + const subtrahendStr = subtrahend.toString() + const subtrahendLength = subtrahendStr.length + + for (let digitIndex = 0; digitIndex < subtrahendLength; digitIndex++) { + const digit = parseInt(subtrahendStr[digitIndex], 10) + const placeValue = subtrahendLength - 1 - digitIndex + + if (digit === 0) continue // Skip zeros + + const currentDigitAtPlace = getDigitAtPlace(currentValue, placeValue) + const startStepCount = steps.length + + // Create base provenance for this digit (negative RHS value for subtraction) + const baseProvenance: TermProvenance = { + rhs: -subtrahend, // Negative to indicate subtraction + rhsDigit: digit, + rhsPlace: placeValue, + rhsPlaceName: getPlaceName(placeValue), + rhsDigitIndex: digitIndex, + rhsValue: -(digit * 10 ** placeValue), // Negative value + } + + // Apply the subtraction algorithm decision tree + const stepResult = processSubtractionDigitAtPlace( + digit, + placeValue, + currentDigitAtPlace, + currentState, + currentValue, + toState, + baseProvenance + ) + + const segmentId = `place-${placeValue}-digit-${digit}-sub` + const segmentStartValue = currentValue + const segmentStartState = { ...currentState } + const placeStart = segmentStartState[placeValue] ?? { + heavenActive: false, + earthActive: 0, + } + const L = placeStart.earthActive + const U: 0 | 1 = placeStart.heavenActive ? 1 : 0 + + steps.push(...stepResult.steps) + currentValue = stepResult.newValue + currentState = stepResult.newState + + const endStepCount = steps.length + const stepIndices = Array.from( + { length: endStepCount - startStepCount }, + (_, i) => startStepCount + i + ) + + if (stepIndices.length === 0) continue + + // Determine pedagogy for subtraction + const plan = determineSubtractionSegmentDecisions( + digit, + placeValue, + currentDigitAtPlace, + stepResult.steps + ) + const goal = inferSubtractionGoal({ + id: segmentId, + place: placeValue, + digit, + a: currentDigitAtPlace, + L, + U, + plan, + goal: '', + stepIndices, + termIndices: stepIndices, + startValue: segmentStartValue, + startState: segmentStartState, + endValue: currentValue, + endState: { ...currentState }, + }) + + const segment: SegmentDraft = { + id: segmentId, + place: placeValue, + digit, + a: currentDigitAtPlace, + L, + U, + plan, + goal, + stepIndices, + termIndices: stepIndices, + startValue: segmentStartValue, + startState: segmentStartState, + endValue: currentValue, + endState: { ...currentState }, + } + + segmentsPlan.push(segment) + } + + const terms = steps.map((step) => step.operation) + return { terms, segmentsPlan, decompositionSteps: steps } +} + +/** + * Process a single digit subtraction at a specific place value. + * Decision tree: + * a >= d: Can subtract without borrowing + * - Direct if enough beads available + * - Five's complement if heaven bead is active but not enough earth beads + * a < d: Need to borrow from higher place + * - Simple borrow if next place > 0 + * - Cascade borrow if next place = 0 + */ +function processSubtractionDigitAtPlace( + digit: number, + placeValue: number, + currentDigitAtPlace: number, + currentState: AbacusState, + currentValue: number, + toState: (n: number) => AbacusState, + baseProvenance: TermProvenance +): { steps: DecompositionStep[]; newValue: number; newState: AbacusState } { + const a = currentDigitAtPlace + const d = digit + + if (a >= d) { + // Can subtract without borrowing + return processDirectSubtraction(d, placeValue, currentState, toState, baseProvenance) + } else { + // Need to borrow from higher place + return processTensBorrow(d, placeValue, currentState, currentValue, toState, baseProvenance) + } +} + +/** + * Handle direct subtraction at a place value (a >= d, no borrow needed). + * Cases: + * - d <= 4 and L >= d: Remove earth beads directly + * - d <= 4 and L < d but U = 1: Five's complement (-5 + (5-d)) + * - d = 5 and U = 1: Deactivate heaven bead + * - d >= 6 and U = 1: Deactivate heaven + remove (d-5) earth beads + */ +function processDirectSubtraction( + digit: number, + placeValue: number, + currentState: AbacusState, + toState: (n: number) => AbacusState, + baseProvenance: TermProvenance +): { steps: DecompositionStep[]; newValue: number; newState: AbacusState } { + const placeState = currentState[placeValue] || { heavenActive: false, earthActive: 0 } + const L = placeState.earthActive + const U = placeState.heavenActive + const steps: DecompositionStep[] = [] + const newState = { ...currentState } + + if (digit <= 4) { + if (L >= digit) { + // Direct: Remove earth beads + const subtractValue = digit * 10 ** placeValue + steps.push({ + operation: `-${subtractValue}`, + description: `Remove ${digit} earth bead${digit > 1 ? 's' : ''} at ${getPlaceName(placeValue)}`, + targetValue: 0, + provenance: { + ...baseProvenance, + termPlace: placeValue, + termPlaceName: getPlaceName(placeValue), + termValue: -subtractValue, + }, + }) + newState[placeValue] = { ...placeState, earthActive: L - digit } + } else if (U) { + // Five's complement: -d = -5 + (5-d) + const complement = 5 - digit + const groupId = `5comp-sub-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}` + const fiveValue = 5 * 10 ** placeValue + const addValue = complement * 10 ** placeValue + + steps.push({ + operation: `-${fiveValue}`, + description: `Deactivate heaven bead at ${getPlaceName(placeValue)}`, + targetValue: 0, + provenance: { + ...baseProvenance, + groupId, + termPlace: placeValue, + termPlaceName: getPlaceName(placeValue), + termValue: -fiveValue, + }, + }) + + steps.push({ + operation: `${addValue}`, + description: `Add ${complement} earth bead${complement > 1 ? 's' : ''} at ${getPlaceName(placeValue)}`, + targetValue: 0, + provenance: { + ...baseProvenance, + groupId, + termPlace: placeValue, + termPlaceName: getPlaceName(placeValue), + termValue: addValue, + }, + }) + + newState[placeValue] = { heavenActive: false, earthActive: L + complement } + } + } else if (digit === 5) { + // Deactivate heaven bead + const subtractValue = 5 * 10 ** placeValue + steps.push({ + operation: `-${subtractValue}`, + description: `Deactivate heaven bead at ${getPlaceName(placeValue)}`, + targetValue: 0, + provenance: { + ...baseProvenance, + termPlace: placeValue, + termPlaceName: getPlaceName(placeValue), + termValue: -subtractValue, + }, + }) + newState[placeValue] = { ...placeState, heavenActive: false } + } else { + // d >= 6: Deactivate heaven + remove (d-5) earth beads + const earthToRemove = digit - 5 + const fiveValue = 5 * 10 ** placeValue + const earthValue = earthToRemove * 10 ** placeValue + + steps.push({ + operation: `-${fiveValue}`, + description: `Deactivate heaven bead at ${getPlaceName(placeValue)}`, + targetValue: 0, + provenance: { + ...baseProvenance, + termPlace: placeValue, + termPlaceName: getPlaceName(placeValue), + termValue: -fiveValue, + }, + }) + + steps.push({ + operation: `-${earthValue}`, + description: `Remove ${earthToRemove} earth bead${earthToRemove > 1 ? 's' : ''} at ${getPlaceName(placeValue)}`, + targetValue: 0, + provenance: { + ...baseProvenance, + termPlace: placeValue, + termPlaceName: getPlaceName(placeValue), + termValue: -earthValue, + }, + }) + + newState[placeValue] = { heavenActive: false, earthActive: L - earthToRemove } + } + + const currentValue = abacusStateToNumber(currentState) + const newValue = abacusStateToNumber(newState) + + return { steps, newValue, newState: toState(newValue) } +} + +/** + * Handle ten's borrow when a < d. + * Borrow 10 from next higher place, then subtract. + * If next place is 0, cascade the borrow. + */ +function processTensBorrow( + digit: number, + placeValue: number, + currentState: AbacusState, + currentValue: number, + toState: (n: number) => AbacusState, + baseProvenance: TermProvenance +): { steps: DecompositionStep[]; newValue: number; newState: AbacusState } { + const steps: DecompositionStep[] = [] + const complementToAdd = 10 - digit // What we add after borrowing 10 + + // Check if this requires cascading (next place is 0) + const nextPlaceDigit = getDigitAtPlace(currentValue, placeValue + 1) + const requiresCascading = nextPlaceDigit === 0 + + const groupId = `10borrow-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}` + + if (requiresCascading) { + // Generate cascading borrow steps + const cascadeSteps = generateCascadeBorrowSteps( + currentValue, + placeValue, + complementToAdd, + baseProvenance, + groupId + ) + steps.push(...cascadeSteps) + } else { + // Simple borrow: -10 from next place, + complement at current place + const borrowValue = 10 ** (placeValue + 1) + const addValue = complementToAdd * 10 ** placeValue + + steps.push({ + operation: `-${borrowValue}`, + description: `Borrow from ${getPlaceName(placeValue + 1)}`, + targetValue: 0, + provenance: { + ...baseProvenance, + groupId, + termPlace: placeValue + 1, + termPlaceName: getPlaceName(placeValue + 1), + termValue: -borrowValue, + }, + }) + + steps.push({ + operation: `${addValue}`, + description: `Add ${complementToAdd} to ${getPlaceName(placeValue)} (ten's complement)`, + targetValue: 0, + provenance: { + ...baseProvenance, + groupId, + termPlace: placeValue, + termPlaceName: getPlaceName(placeValue), + termValue: addValue, + }, + }) + } + + // Calculate new value: subtract digit at this place + const newValue = currentValue - digit * 10 ** placeValue + + return { + steps, + newValue, + newState: toState(newValue), + } +} + +/** + * Generate cascade borrow steps when borrowing through zeros. + * Find first non-zero higher place, subtract there, fill 9s in between. + */ +function generateCascadeBorrowSteps( + currentValue: number, + startPlace: number, + complementToAdd: number, + baseProvenance: TermProvenance, + groupId: string +): DecompositionStep[] { + const steps: DecompositionStep[] = [] + + // Find first non-zero place to borrow from + let borrowPlace = startPlace + 1 + const maxCheck = Math.max(1, Math.floor(Math.log10(Math.max(1, currentValue))) + 1) + 2 + while (getDigitAtPlace(currentValue, borrowPlace) === 0 && borrowPlace <= maxCheck) { + borrowPlace += 1 + } + + // Subtract from the highest non-zero place + const borrowValue = 10 ** borrowPlace + steps.push({ + operation: `-${borrowValue}`, + description: `Borrow from ${getPlaceName(borrowPlace)} (cascade)`, + targetValue: 0, + provenance: { + ...baseProvenance, + groupId, + termPlace: borrowPlace, + termPlaceName: getPlaceName(borrowPlace), + termValue: -borrowValue, + }, + }) + + // Fill all zeros between borrowPlace and startPlace+1 with 9s + for (let fillPlace = borrowPlace - 1; fillPlace > startPlace; fillPlace--) { + const digitAtFillPlace = getDigitAtPlace(currentValue, fillPlace) + if (digitAtFillPlace === 0) { + const fillValue = 9 * 10 ** fillPlace + steps.push({ + operation: `${fillValue}`, + description: `Fill ${getPlaceName(fillPlace)} with 9 (cascade)`, + targetValue: 0, + provenance: { + ...baseProvenance, + groupId, + termPlace: fillPlace, + termPlaceName: getPlaceName(fillPlace), + termValue: fillValue, + }, + }) + } + } + + // Add complement at the original place + const addValue = complementToAdd * 10 ** startPlace + steps.push({ + operation: `${addValue}`, + description: `Add ${complementToAdd} to ${getPlaceName(startPlace)} (ten's complement)`, + targetValue: 0, + provenance: { + ...baseProvenance, + groupId, + termPlace: startPlace, + termPlaceName: getPlaceName(startPlace), + termValue: addValue, + }, + }) + + return steps +} + +/** + * Determine segment decisions for subtraction operations. + */ +function determineSubtractionSegmentDecisions( + digit: number, + place: number, + currentDigit: number, + steps: DecompositionStep[] +): SegmentDecision[] { + // Check if this is a borrow operation (has positive terms after negative) + const hasNegativeTen = steps.some( + (s) => s.operation.startsWith('-') && isPowerOfTenGE10(Math.abs(parseInt(s.operation, 10))) + ) + const hasPositiveAfterNegative = + steps.some((s) => !s.operation.startsWith('-')) && + steps.some((s) => s.operation.startsWith('-')) + + // Check for five's complement pattern: -5 followed by positive + const hasFiveSub = steps.some((s) => { + const val = parseInt(s.operation, 10) + return val < 0 && Math.abs(val) === 5 * 10 ** place + }) + const hasPositiveAtPlace = steps.some((s) => { + const val = parseInt(s.operation, 10) + return val > 0 && val < 10 ** (place + 1) && val >= 10 ** place + }) + + if (currentDigit >= digit) { + // No borrow needed + if (hasFiveSub && hasPositiveAtPlace) { + // Five's complement subtraction: -5 + n + return [ + { + rule: 'FiveComplement', + conditions: [`a=${currentDigit} >= d=${digit}`, `L < d, U=1`], + explanation: [ + 'Not enough earth beads to remove directly.', + `Use -5 + ${5 - digit}; the heaven bead provides the 5.`, + ], + }, + ] + } + return [ + { + rule: 'Direct', + conditions: [`a=${currentDigit} >= d=${digit}`], + explanation: ['Can subtract directly without borrowing.'], + }, + ] + } + + // Need to borrow + const hasCascade = steps.length > 2 && steps.some((s) => s.description?.includes('cascade')) + + if (hasCascade) { + return [ + { + rule: 'TenComplement', + conditions: [`a=${currentDigit} < d=${digit}`, `next place = 0`], + explanation: ['Need to borrow, but next place is 0.', 'Cascade borrow through zeros.'], + }, + { + rule: 'Cascade', + conditions: ['zeros in borrowing path'], + explanation: ['Fill intermediate zeros with 9s.'], + }, + ] + } + + return [ + { + rule: 'TenComplement', + conditions: [`a=${currentDigit} < d=${digit}`], + explanation: [ + 'Need to borrow from next higher place.', + `Use -10 + ${10 - digit} to subtract ${digit}.`, + ], + }, + ] +} + +/** + * Infer a human-readable goal for subtraction segments. + */ +function inferSubtractionGoal(seg: SegmentDraft): string { + const placeName = getPlaceName(seg.place) + switch (seg.plan[0]?.rule) { + case 'Direct': + return `Subtract ${seg.digit} from ${placeName} directly` + case 'FiveComplement': + return `Subtract ${seg.digit} from ${placeName} using 5's complement` + case 'TenComplement': + return `Subtract ${seg.digit} from ${placeName} with a borrow` + case 'Cascade': + return `Borrow through ${placeName}+ from nearest non-zero place` + default: + return `Subtract at ${placeName}` + } +} + +// ============================================================================= +// END SUBTRACTION IMPLEMENTATION +// ============================================================================= + /** * Generate English instruction from mathematical term */ @@ -1491,6 +2131,7 @@ export function buildFullDecompositionWithPositions( termPositions: Array<{ startIndex: number; endIndex: number }> } { const difference = targetValue - startValue + const isSubtraction = difference < 0 // Handle zero difference special case if (difference === 0) { @@ -1501,6 +2142,8 @@ export function buildFullDecompositionWithPositions( } // Group consecutive complement terms into segments + // For subtraction: group negative term followed by positive(s) as complement + // For addition: group positive term followed by negative(s) as complement const segments: Array<{ terms: string[] isComplement: boolean @@ -1510,28 +2153,51 @@ export function buildFullDecompositionWithPositions( while (i < terms.length) { const currentTerm = terms[i] - // Check if this starts a complement sequence (positive term followed by negative(s)) - if (i + 1 < terms.length && !currentTerm.startsWith('-') && terms[i + 1].startsWith('-')) { - // Collect all consecutive negative terms after this positive term - const complementTerms = [currentTerm] - let j = i + 1 - while (j < terms.length && terms[j].startsWith('-')) { - complementTerms.push(terms[j]) - j++ - } + if (isSubtraction) { + // Subtraction: complement is negative followed by positive(s) + // e.g., "-5" followed by "1" for five's complement subtraction + if (i + 1 < terms.length && currentTerm.startsWith('-') && !terms[i + 1].startsWith('-')) { + const complementTerms = [currentTerm] + let j = i + 1 + while (j < terms.length && !terms[j].startsWith('-')) { + complementTerms.push(terms[j]) + j++ + } - segments.push({ - terms: complementTerms, - isComplement: true, - }) - i = j // Jump past all consumed terms + segments.push({ + terms: complementTerms, + isComplement: true, + }) + i = j + } else { + segments.push({ + terms: [currentTerm], + isComplement: false, + }) + i++ + } } else { - // Single term (not part of complement) - segments.push({ - terms: [currentTerm], - isComplement: false, - }) - i++ + // Addition: complement is positive followed by negative(s) + if (i + 1 < terms.length && !currentTerm.startsWith('-') && terms[i + 1].startsWith('-')) { + const complementTerms = [currentTerm] + let j = i + 1 + while (j < terms.length && terms[j].startsWith('-')) { + complementTerms.push(terms[j]) + j++ + } + + segments.push({ + terms: complementTerms, + isComplement: true, + }) + i = j + } else { + segments.push({ + terms: [currentTerm], + isComplement: false, + }) + i++ + } } } @@ -1541,24 +2207,43 @@ export function buildFullDecompositionWithPositions( segments.forEach((segment, segmentIndex) => { if (segment.isComplement) { - // Format as parenthesized complement: (10 - 3) or (1000 - 900 - 90 - 2) - const positiveStr = segment.terms[0] - const negativeStrs = segment.terms.slice(1).map((t) => t.substring(1)) // Remove - signs + if (isSubtraction) { + // Subtraction complement: (-5 + 1) format + const negativeStr = segment.terms[0].substring(1) // Remove '-' sign + const positiveStrs = segment.terms.slice(1) - const segmentStr = `(${positiveStr} - ${negativeStrs.join(' - ')})` + const segmentStr = `(-${negativeStr} + ${positiveStrs.join(' + ')})` - if (segmentIndex === 0) { - termString = segmentStr + if (segmentIndex === 0) { + termString = segmentStr + } else { + termString += ` + ${segmentStr}` + } } else { - termString += ` + ${segmentStr}` + // Addition complement: (10 - 3) format + const positiveStr = segment.terms[0] + const negativeStrs = segment.terms.slice(1).map((t) => t.substring(1)) + + const segmentStr = `(${positiveStr} - ${negativeStrs.join(' - ')})` + + if (segmentIndex === 0) { + termString = segmentStr + } else { + termString += ` + ${segmentStr}` + } } } else { // Single term const term = segment.terms[0] if (segmentIndex === 0) { - termString = term + if (isSubtraction && term.startsWith('-')) { + // First term in subtraction is negative, wrap in parens + termString = `(${term})` + } else { + termString = term + } } else if (term.startsWith('-')) { - termString += ` ${term}` // Keep negative sign + termString += ` + (${term})` // Wrap negative terms for subtraction } else { termString += ` + ${term}` } @@ -1566,7 +2251,13 @@ export function buildFullDecompositionWithPositions( }) // Build full decomposition - const leftSide = `${startValue} + ${difference} = ${startValue} + ` + let leftSide: string + if (isSubtraction) { + // Format: "startValue - |difference| = startValue + " + leftSide = `${startValue} - ${Math.abs(difference)} = ${startValue} + ` + } else { + leftSide = `${startValue} + ${difference} = ${startValue} + ` + } const rightSide = ` = ${targetValue}` const fullDecomposition = leftSide + termString + rightSide @@ -1584,28 +2275,54 @@ export function buildFullDecompositionWithPositions( // Position within parenthesized complement currentPos += 1 // Skip opening '(' - segment.terms.forEach((term, termInSegmentIndex) => { - const startIndex = currentPos + if (isSubtraction) { + // Subtraction complement: (-5 + 1) + segment.terms.forEach((term, termInSegmentIndex) => { + if (termInSegmentIndex === 0) { + // Negative term - position on number part (skip -) + currentPos += 1 // Skip '-' + const numberStr = term.substring(1) + termPositions[segmentTermIndex] = { + startIndex: currentPos, + endIndex: currentPos + numberStr.length, + } + currentPos += numberStr.length + } else { + // Positive term + currentPos += 3 // Skip ' + ' + termPositions[segmentTermIndex] = { + startIndex: currentPos, + endIndex: currentPos + term.length, + } + currentPos += term.length + } + segmentTermIndex++ + }) + } else { + // Addition complement: (10 - 3) + segment.terms.forEach((term, termInSegmentIndex) => { + const startIndex = currentPos - if (termInSegmentIndex === 0) { - // Positive term - termPositions[segmentTermIndex] = { - startIndex, - endIndex: startIndex + term.length, + if (termInSegmentIndex === 0) { + // Positive term + termPositions[segmentTermIndex] = { + startIndex, + endIndex: startIndex + term.length, + } + currentPos += term.length + } else { + // Negative term (but we position on just the number part) + currentPos += 3 // Skip ' - ' + const numberStr = term.substring(1) // Remove '-' + termPositions[segmentTermIndex] = { + startIndex: currentPos, + endIndex: currentPos + numberStr.length, + } + currentPos += numberStr.length } - currentPos += term.length - } else { - // Negative term (but we position on just the number part) - currentPos += 3 // Skip ' - ' - const numberStr = term.substring(1) // Remove '-' - termPositions[segmentTermIndex] = { - startIndex: currentPos, - endIndex: currentPos + numberStr.length, - } - currentPos += numberStr.length - } - segmentTermIndex++ - }) + segmentTermIndex++ + }) + } currentPos += 1 // Skip closing ')' } else { @@ -1613,18 +2330,37 @@ export function buildFullDecompositionWithPositions( const term = segment.terms[0] if (segmentIndex > 0) { - if (term.startsWith('-')) { - currentPos += 1 // Skip ' ' before negative - } else { - currentPos += 3 // Skip ' + ' - } + currentPos += 3 // Skip ' + ' } - const isNegative = term.startsWith('-') - const startIndex = isNegative ? currentPos + 1 : currentPos // skip the '−' for mapping - const endIndex = isNegative ? startIndex + (term.length - 1) : startIndex + term.length - termPositions[segmentTermIndex] = { startIndex, endIndex } - currentPos += term.length // actual text includes the '−' + if (isSubtraction && term.startsWith('-')) { + // Wrapped negative: (−5) + currentPos += 1 // Skip '(' + currentPos += 1 // Skip '−' + const numberStr = term.substring(1) + termPositions[segmentTermIndex] = { + startIndex: currentPos, + endIndex: currentPos + numberStr.length, + } + currentPos += numberStr.length + currentPos += 1 // Skip ')' + } else if (term.startsWith('-')) { + // Unwrapped negative (shouldn't happen often) + currentPos += 1 // Skip '−' + const numberStr = term.substring(1) + termPositions[segmentTermIndex] = { + startIndex: currentPos, + endIndex: currentPos + numberStr.length, + } + currentPos += numberStr.length + } else { + // Positive term + termPositions[segmentTermIndex] = { + startIndex: currentPos, + endIndex: currentPos + term.length, + } + currentPos += term.length + } segmentTermIndex++ } })