feat: implement dynamic bead diff algorithm for state transitions
Creates a new algorithm that calculates exactly which beads need to move between any two abacus states, providing incremental state changes with pedagogical ordering (removals before additions). Key features: - calculateBeadDiff() for state-to-state transitions - calculateBeadDiffFromValues() for number-to-number transitions - Support for multi-step sequences with intermediate states - Human-readable summaries of bead movements - Comprehensive test suite with real tutorial examples 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
268
apps/web/src/utils/beadDiff.ts
Normal file
268
apps/web/src/utils/beadDiff.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
// Dynamic bead diff algorithm for calculating transitions between abacus states
|
||||
// Provides arrows, highlights, and movement directions for tutorial UI
|
||||
|
||||
import { ValidPlaceValues } from '@soroban/abacus-react'
|
||||
import { AbacusState, BeadHighlight, numberToAbacusState, calculateBeadChanges } from './abacusInstructionGenerator'
|
||||
|
||||
export interface BeadDiffResult {
|
||||
placeValue: ValidPlaceValues
|
||||
beadType: 'heaven' | 'earth'
|
||||
position?: number
|
||||
direction: 'activate' | 'deactivate'
|
||||
order: number // Order of operations for animations
|
||||
}
|
||||
|
||||
export interface BeadDiffOutput {
|
||||
changes: BeadDiffResult[]
|
||||
highlights: BeadHighlight[]
|
||||
hasChanges: boolean
|
||||
summary: string
|
||||
}
|
||||
|
||||
/**
|
||||
* THE BEAD DIFF ALGORITHM
|
||||
*
|
||||
* Takes current and desired abacus states and returns exactly which beads
|
||||
* need to move with arrows and highlights for the tutorial UI.
|
||||
*
|
||||
* This is the core "diff" function that keeps tutorial highlights in sync.
|
||||
*/
|
||||
export function calculateBeadDiff(
|
||||
fromState: AbacusState,
|
||||
toState: AbacusState
|
||||
): BeadDiffOutput {
|
||||
const { additions, removals } = calculateBeadChanges(fromState, toState)
|
||||
|
||||
const changes: BeadDiffResult[] = []
|
||||
const highlights: BeadHighlight[] = []
|
||||
let order = 0
|
||||
|
||||
// Process removals first (pedagogical order: clear before adding)
|
||||
removals.forEach(removal => {
|
||||
changes.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position,
|
||||
direction: 'deactivate',
|
||||
order: order++
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: removal.placeValue,
|
||||
beadType: removal.beadType,
|
||||
position: removal.position
|
||||
})
|
||||
})
|
||||
|
||||
// Process additions second (pedagogical order: add after clearing)
|
||||
additions.forEach(addition => {
|
||||
changes.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position,
|
||||
direction: 'activate',
|
||||
order: order++
|
||||
})
|
||||
|
||||
highlights.push({
|
||||
placeValue: addition.placeValue,
|
||||
beadType: addition.beadType,
|
||||
position: addition.position
|
||||
})
|
||||
})
|
||||
|
||||
// Generate summary
|
||||
const summary = generateDiffSummary(changes)
|
||||
|
||||
return {
|
||||
changes,
|
||||
highlights,
|
||||
hasChanges: changes.length > 0,
|
||||
summary
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bead diff from numeric values
|
||||
* Convenience function for when you have numbers instead of states
|
||||
*/
|
||||
export function calculateBeadDiffFromValues(
|
||||
fromValue: number,
|
||||
toValue: number,
|
||||
maxPlaces: number = 5
|
||||
): BeadDiffOutput {
|
||||
const fromState = numberToAbacusState(fromValue, maxPlaces)
|
||||
const toState = numberToAbacusState(toValue, maxPlaces)
|
||||
return calculateBeadDiff(fromState, toState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate step-by-step bead diffs for multi-step operations
|
||||
* This is used for tutorial multi-step instructions where we want to show
|
||||
* the progression through intermediate states
|
||||
*/
|
||||
export function calculateMultiStepBeadDiffs(
|
||||
startValue: number,
|
||||
steps: Array<{ expectedValue: number; instruction: string }>
|
||||
): Array<{
|
||||
stepIndex: number
|
||||
instruction: string
|
||||
diff: BeadDiffOutput
|
||||
fromValue: number
|
||||
toValue: number
|
||||
}> {
|
||||
const stepDiffs = []
|
||||
let currentValue = startValue
|
||||
|
||||
steps.forEach((step, index) => {
|
||||
const diff = calculateBeadDiffFromValues(currentValue, step.expectedValue)
|
||||
|
||||
stepDiffs.push({
|
||||
stepIndex: index,
|
||||
instruction: step.instruction,
|
||||
diff,
|
||||
fromValue: currentValue,
|
||||
toValue: step.expectedValue
|
||||
})
|
||||
|
||||
currentValue = step.expectedValue
|
||||
})
|
||||
|
||||
return stepDiffs
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a human-readable summary of what the diff does
|
||||
* Respects pedagogical order: removals first, then additions
|
||||
*/
|
||||
function generateDiffSummary(changes: BeadDiffResult[]): string {
|
||||
if (changes.length === 0) {
|
||||
return 'No changes needed'
|
||||
}
|
||||
|
||||
// Sort by order to respect pedagogical sequence
|
||||
const sortedChanges = [...changes].sort((a, b) => a.order - b.order)
|
||||
|
||||
const deactivations = sortedChanges.filter(c => c.direction === 'deactivate')
|
||||
const activations = sortedChanges.filter(c => c.direction === 'activate')
|
||||
|
||||
const parts: string[] = []
|
||||
|
||||
// Process deactivations first (pedagogical order)
|
||||
if (deactivations.length > 0) {
|
||||
const deactivationsByPlace = groupByPlace(deactivations)
|
||||
Object.entries(deactivationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place))
|
||||
const heavenBeads = beads.filter(b => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter(b => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`remove heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`remove ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Process activations second (pedagogical order)
|
||||
if (activations.length > 0) {
|
||||
const activationsByPlace = groupByPlace(activations)
|
||||
Object.entries(activationsByPlace).forEach(([place, beads]) => {
|
||||
const placeName = getPlaceName(parseInt(place))
|
||||
const heavenBeads = beads.filter(b => b.beadType === 'heaven')
|
||||
const earthBeads = beads.filter(b => b.beadType === 'earth')
|
||||
|
||||
if (heavenBeads.length > 0) {
|
||||
parts.push(`add heaven bead in ${placeName}`)
|
||||
}
|
||||
if (earthBeads.length > 0) {
|
||||
const count = earthBeads.length
|
||||
parts.push(`add ${count} earth bead${count > 1 ? 's' : ''} in ${placeName}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return parts.join(', then ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Group bead changes by place value
|
||||
*/
|
||||
function groupByPlace(changes: BeadDiffResult[]): { [place: string]: BeadDiffResult[] } {
|
||||
return changes.reduce((groups, change) => {
|
||||
const place = change.placeValue.toString()
|
||||
if (!groups[place]) {
|
||||
groups[place] = []
|
||||
}
|
||||
groups[place].push(change)
|
||||
return groups
|
||||
}, {} as { [place: string]: BeadDiffResult[] })
|
||||
}
|
||||
|
||||
/**
|
||||
* Get human-readable place name
|
||||
*/
|
||||
function getPlaceName(place: number): string {
|
||||
switch (place) {
|
||||
case 0: return 'ones column'
|
||||
case 1: return 'tens column'
|
||||
case 2: return 'hundreds column'
|
||||
case 3: return 'thousands column'
|
||||
default: return `place ${place} column`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if two abacus states are equal
|
||||
*/
|
||||
export function areStatesEqual(state1: AbacusState, state2: AbacusState): boolean {
|
||||
const places1 = Object.keys(state1).map(k => parseInt(k)).sort()
|
||||
const places2 = Object.keys(state2).map(k => parseInt(k)).sort()
|
||||
|
||||
if (places1.length !== places2.length) return false
|
||||
|
||||
for (const place of places1) {
|
||||
const bead1 = state1[place]
|
||||
const bead2 = state2[place]
|
||||
|
||||
if (!bead2) return false
|
||||
if (bead1.heavenActive !== bead2.heavenActive) return false
|
||||
if (bead1.earthActive !== bead2.earthActive) return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a bead diff is feasible (no impossible bead states)
|
||||
*/
|
||||
export function validateBeadDiff(diff: BeadDiffOutput): {
|
||||
isValid: boolean
|
||||
errors: string[]
|
||||
} {
|
||||
const errors: string[] = []
|
||||
|
||||
// Check for impossible earth bead counts
|
||||
const earthChanges = diff.changes.filter(c => c.beadType === 'earth')
|
||||
const earthByPlace = groupByPlace(earthChanges)
|
||||
|
||||
Object.entries(earthByPlace).forEach(([place, changes]) => {
|
||||
const activations = changes.filter(c => c.direction === 'activate').length
|
||||
const deactivations = changes.filter(c => c.direction === 'deactivate').length
|
||||
const netChange = activations - deactivations
|
||||
|
||||
if (netChange > 4) {
|
||||
errors.push(`Place ${place}: Cannot have more than 4 earth beads`)
|
||||
}
|
||||
if (netChange < 0) {
|
||||
errors.push(`Place ${place}: Cannot have negative earth beads`)
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
}
|
||||
}
|
||||
233
apps/web/src/utils/test/beadDiff.test.ts
Normal file
233
apps/web/src/utils/test/beadDiff.test.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
calculateBeadDiff,
|
||||
calculateBeadDiffFromValues,
|
||||
calculateMultiStepBeadDiffs,
|
||||
areStatesEqual,
|
||||
validateBeadDiff
|
||||
} from '../beadDiff'
|
||||
import { numberToAbacusState } from '../abacusInstructionGenerator'
|
||||
|
||||
describe('Bead Diff Algorithm', () => {
|
||||
describe('Basic State Transitions', () => {
|
||||
it('should calculate diff for simple addition: 0 + 1', () => {
|
||||
const diff = calculateBeadDiffFromValues(0, 1)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
expect(diff.changes).toHaveLength(1)
|
||||
expect(diff.changes[0]).toEqual({
|
||||
placeValue: 0,
|
||||
beadType: 'earth',
|
||||
position: 0,
|
||||
direction: 'activate',
|
||||
order: 0
|
||||
})
|
||||
expect(diff.summary).toBe('add 1 earth bead in ones column')
|
||||
})
|
||||
|
||||
it('should calculate diff for heaven bead: 0 + 5', () => {
|
||||
const diff = calculateBeadDiffFromValues(0, 5)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
expect(diff.changes).toHaveLength(1)
|
||||
expect(diff.changes[0]).toEqual({
|
||||
placeValue: 0,
|
||||
beadType: 'heaven',
|
||||
direction: 'activate',
|
||||
order: 0
|
||||
})
|
||||
expect(diff.summary).toBe('add heaven bead in ones column')
|
||||
})
|
||||
|
||||
it('should calculate diff for complement operation: 3 + 4 = 7', () => {
|
||||
const diff = calculateBeadDiffFromValues(3, 7)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
expect(diff.changes).toHaveLength(2) // Remove 1 earth, add heaven
|
||||
|
||||
// Should remove 1 earth bead first (pedagogical order)
|
||||
const removals = diff.changes.filter(c => c.direction === 'deactivate')
|
||||
const additions = diff.changes.filter(c => c.direction === 'activate')
|
||||
|
||||
expect(removals).toHaveLength(1) // Remove 1 earth bead (position 2)
|
||||
expect(additions).toHaveLength(1) // Add heaven bead
|
||||
|
||||
// Removals should come first in order
|
||||
expect(removals[0].order).toBeLessThan(additions[0].order)
|
||||
|
||||
expect(diff.summary).toContain('remove 1 earth bead')
|
||||
expect(diff.summary).toContain('add heaven bead')
|
||||
})
|
||||
|
||||
it('should calculate diff for ten transition: 9 + 1 = 10', () => {
|
||||
const diff = calculateBeadDiffFromValues(9, 10)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
|
||||
// Should remove heaven + 4 earth in ones, add 1 earth in tens
|
||||
const onesChanges = diff.changes.filter(c => c.placeValue === 0)
|
||||
const tensChanges = diff.changes.filter(c => c.placeValue === 1)
|
||||
|
||||
expect(onesChanges).toHaveLength(5) // Remove heaven + 4 earth
|
||||
expect(tensChanges).toHaveLength(1) // Add 1 earth in tens
|
||||
|
||||
expect(diff.summary).toContain('tens column')
|
||||
expect(diff.summary).toContain('ones column')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Multi-Step Operations', () => {
|
||||
it('should calculate multi-step diff for 3 + 14 = 17', () => {
|
||||
const steps = [
|
||||
{ expectedValue: 13, instruction: 'Add 10' },
|
||||
{ expectedValue: 17, instruction: 'Add 4 using complement' }
|
||||
]
|
||||
|
||||
const multiStepDiffs = calculateMultiStepBeadDiffs(3, steps)
|
||||
|
||||
expect(multiStepDiffs).toHaveLength(2)
|
||||
|
||||
// Step 1: 3 → 13 (add 1 earth bead in tens)
|
||||
const step1 = multiStepDiffs[0]
|
||||
expect(step1.fromValue).toBe(3)
|
||||
expect(step1.toValue).toBe(13)
|
||||
expect(step1.diff.changes).toHaveLength(1)
|
||||
expect(step1.diff.changes[0].placeValue).toBe(1) // tens
|
||||
expect(step1.diff.changes[0].beadType).toBe('earth')
|
||||
expect(step1.diff.changes[0].direction).toBe('activate')
|
||||
|
||||
// Step 2: 13 → 17 (complement operation in ones)
|
||||
const step2 = multiStepDiffs[1]
|
||||
expect(step2.fromValue).toBe(13)
|
||||
expect(step2.toValue).toBe(17)
|
||||
expect(step2.diff.changes.length).toBeGreaterThan(1) // Multiple bead movements
|
||||
})
|
||||
})
|
||||
|
||||
describe('Edge Cases and Validation', () => {
|
||||
it('should return no changes for identical states', () => {
|
||||
const diff = calculateBeadDiffFromValues(5, 5)
|
||||
|
||||
expect(diff.hasChanges).toBe(false)
|
||||
expect(diff.changes).toHaveLength(0)
|
||||
expect(diff.summary).toBe('No changes needed')
|
||||
})
|
||||
|
||||
it('should handle large numbers correctly', () => {
|
||||
const diff = calculateBeadDiffFromValues(0, 999)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
expect(diff.changes.length).toBeGreaterThan(0)
|
||||
|
||||
// Should have changes in hundreds, tens, and ones places
|
||||
const places = new Set(diff.changes.map(c => c.placeValue))
|
||||
expect(places).toContain(0) // ones
|
||||
expect(places).toContain(1) // tens
|
||||
expect(places).toContain(2) // hundreds
|
||||
})
|
||||
|
||||
it('should validate impossible bead states', () => {
|
||||
// Create a diff that would result in more than 4 earth beads
|
||||
const fromState = numberToAbacusState(0)
|
||||
const toState = numberToAbacusState(0)
|
||||
toState[0] = { heavenActive: false, earthActive: 5 } // Invalid: too many earth beads
|
||||
|
||||
const diff = calculateBeadDiff(fromState, toState)
|
||||
const validation = validateBeadDiff(diff)
|
||||
|
||||
expect(validation.isValid).toBe(false)
|
||||
expect(validation.errors.length).toBeGreaterThan(0)
|
||||
expect(validation.errors[0]).toContain('Cannot have more than 4 earth beads')
|
||||
})
|
||||
|
||||
it('should correctly identify equal states', () => {
|
||||
const state1 = numberToAbacusState(42)
|
||||
const state2 = numberToAbacusState(42)
|
||||
const state3 = numberToAbacusState(43)
|
||||
|
||||
expect(areStatesEqual(state1, state2)).toBe(true)
|
||||
expect(areStatesEqual(state1, state3)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Pedagogical Ordering', () => {
|
||||
it('should process removals before additions', () => {
|
||||
// Test a case that requires both removing and adding beads
|
||||
const diff = calculateBeadDiffFromValues(7, 2) // 7 → 2: remove heaven, remove 2 earth, add 2 earth
|
||||
|
||||
const removals = diff.changes.filter(c => c.direction === 'deactivate')
|
||||
const additions = diff.changes.filter(c => c.direction === 'activate')
|
||||
|
||||
if (removals.length > 0 && additions.length > 0) {
|
||||
// All removals should have lower order numbers than additions
|
||||
const maxRemovalOrder = Math.max(...removals.map(r => r.order))
|
||||
const minAdditionOrder = Math.min(...additions.map(a => a.order))
|
||||
|
||||
expect(maxRemovalOrder).toBeLessThan(minAdditionOrder)
|
||||
}
|
||||
})
|
||||
|
||||
it('should maintain consistent ordering for animation', () => {
|
||||
const diff = calculateBeadDiffFromValues(0, 23) // Complex operation
|
||||
|
||||
// Orders should be consecutive starting from 0
|
||||
const orders = diff.changes.map(c => c.order).sort((a, b) => a - b)
|
||||
|
||||
for (let i = 0; i < orders.length; i++) {
|
||||
expect(orders[i]).toBe(i)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('Real Tutorial Examples', () => {
|
||||
it('should handle the classic "3 + 14 = 17" example', () => {
|
||||
console.log('=== Testing 3 + 14 = 17 ===')
|
||||
|
||||
const diff = calculateBeadDiffFromValues(3, 17)
|
||||
|
||||
console.log('Changes:', diff.changes)
|
||||
console.log('Summary:', diff.summary)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
expect(diff.summary).toBeDefined()
|
||||
|
||||
// Should involve both tens and ones places
|
||||
const places = new Set(diff.changes.map(c => c.placeValue))
|
||||
expect(places).toContain(0) // ones
|
||||
expect(places).toContain(1) // tens
|
||||
})
|
||||
|
||||
it('should handle "7 + 4 = 11" ten complement', () => {
|
||||
console.log('=== Testing 7 + 4 = 11 ===')
|
||||
|
||||
const diff = calculateBeadDiffFromValues(7, 11)
|
||||
|
||||
console.log('Changes:', diff.changes)
|
||||
console.log('Summary:', diff.summary)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
|
||||
// Should involve both tens and ones places
|
||||
const places = new Set(diff.changes.map(c => c.placeValue))
|
||||
expect(places).toContain(0) // ones
|
||||
expect(places).toContain(1) // tens
|
||||
})
|
||||
|
||||
it('should handle "99 + 1 = 100" boundary crossing', () => {
|
||||
console.log('=== Testing 99 + 1 = 100 ===')
|
||||
|
||||
const diff = calculateBeadDiffFromValues(99, 100)
|
||||
|
||||
console.log('Changes:', diff.changes)
|
||||
console.log('Summary:', diff.summary)
|
||||
|
||||
expect(diff.hasChanges).toBe(true)
|
||||
|
||||
// Should involve ones, tens, and hundreds places
|
||||
const places = new Set(diff.changes.map(c => c.placeValue))
|
||||
expect(places).toContain(0) // ones
|
||||
expect(places).toContain(1) // tens
|
||||
expect(places).toContain(2) // hundreds
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user