fix: improve pedagogical segment detection and instruction quality

High-impact fixes for production-ready segment analysis:

Cascade Detection:
- Fix ten-complement cascade detection using place analysis
- Detect cascades via tenAddPlace > place + 1 OR negatives.length >= 2
- Replace brittle positives.length > 1 heuristic with robust logic

Instruction Generation:
- Add isPowerOfTenGE10 helper to distinguish place operations (≥10)
- Fix power-of-ten usage in generateInstructionFromTerm
- Use getPlaceName consistently across all step instructions
- Improve bead movement sorting with clearer stable comparator

Test Coverage:
- Add 4 targeted tests for advanced segment rules and ranges
- Test five-complement, ten-complement, and cascade detection
- Verify segment ranges use termPositions (robust to repeated terms)
- Validate step containment within segment boundaries

Quality improvements:
- Better cascade signal detection for complex operations
- Consistent place naming beyond hundreds/thousands
- Clearer sorting logic for bead animations
- More precise instruction language for place operations

All 46 tests passing (up from 42), maintaining 13ms runtime.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-09-25 11:24:16 -05:00
parent 570eac17f1
commit 0ac51aefa7
2 changed files with 68 additions and 23 deletions

View File

@@ -374,4 +374,46 @@ describe('Pedagogical Algorithm - Core Validation', () => {
})
})
})
describe('Pedagogical Segments - Advanced Rules and Ranges', () => {
// 1) Five-complement at ones (shows rule + expression)
it('segments: five-complement ones (3→ +2)', () => {
const { segments, fullDecomposition } = generateUnifiedInstructionSequence(3, 5)
const s0 = segments.find(s => s.place === 0)!
expect(s0.plan[0].rule).toBe('FiveComplement')
expect(s0.expression).toMatch(/^\(5.*-\s*3\)$/) // "(5 - 3)"
// range points to the parenthesized group
const text = fullDecomposition.slice(s0.termRange.startIndex, s0.termRange.endIndex)
expect(text.startsWith('(') && text.endsWith(')')).toBe(true)
})
// 2) Ten-complement no cascade (19 + 1)
it('segments: ten-complement without cascade (19→ +1)', () => {
const { segments } = generateUnifiedInstructionSequence(19, 20)
const tensSeg = segments.find(s => s.place === 0)!
expect(tensSeg.plan.some(p => p.rule === 'TenComplement')).toBe(true)
expect(tensSeg.plan.some(p => p.rule === 'Cascade')).toBe(false)
})
// 3) Ten-complement with cascade (199 + 1)
it('segments: ten-complement with cascade ripple', () => {
const { segments } = generateUnifiedInstructionSequence(199, 200)
const onesSeg = segments.find(s => s.place === 0)!
expect(onesSeg.plan.some(p => p.rule === 'Cascade')).toBe(true)
})
// 4) Segment range robustness with repeated terms
it('segment ranges use termPositions not string search', () => {
const { segments, steps, fullDecomposition } = generateUnifiedInstructionSequence(3478, 3500) // 3478 + 22
const tensSeg = segments.find(s => s.place === 1)!
const text = fullDecomposition.slice(tensSeg.termRange.startIndex, tensSeg.termRange.endIndex)
// should be "(20 - ...)" group and not pick the "20" inside "120" if any
expect(text.includes('20')).toBe(true)
// also, every step in the segment should lie inside segment range
tensSeg.stepIndices.forEach(i => {
const { startIndex, endIndex } = steps[i].termPosition
expect(startIndex >= tensSeg.termRange.startIndex && endIndex <= tensSeg.termRange.endIndex).toBe(true)
})
})
})
})

View File

@@ -120,6 +120,8 @@ function isPowerOfTen(n: number): boolean {
if (n < 1) return false
return /^10*$/.test(n.toString())
}
const isPowerOfTenGE10 = (n: number) => n >= 10 && isPowerOfTen(n)
function inferGoal(seg: SegmentDraft): string {
const placeName = getPlaceName(seg.place)
switch (seg.plan[0]?.rule) {
@@ -264,21 +266,23 @@ function determineSegmentDecisions(
}]
}
const hasPositive = steps.some(s => !s.operation.startsWith('-'))
const hasNegative = steps.some(s => s.operation.startsWith('-'))
const positives = steps.filter(s => !s.operation.startsWith('-')).map(s => parseInt(s.operation, 10))
const negatives = steps.filter(s => s.operation.startsWith('-')).map(s => Math.abs(parseInt(s.operation, 10)))
if (hasPositive && hasNegative) {
const positives = steps.filter(s => !s.operation.startsWith('-')).map(s => parseInt(s.operation, 10))
const hasFiveAdd = positives.some(v => Number.isInteger(v / 5) && isPowerOfTen(v / 5))
const hasTenAdd = positives.some(v => isPowerOfTen(v))
const hasFiveAdd = positives.some(v => Number.isInteger(v / 5) && isPowerOfTen(v / 5))
const tenAdd = positives.find(v => isPowerOfTenGE10(v))
const hasTenAdd = tenAdd !== undefined
if (hasFiveAdd && !hasTenAdd) {
return decisionForFiveComplement(currentDigit, digit)
}
if (hasTenAdd) {
const nextIs9 = positives.length > 1 // cascade if ripple seen
return decisionForTenComplement(currentDigit, digit, nextIs9)
}
if (hasFiveAdd && !hasTenAdd) {
return decisionForFiveComplement(currentDigit, digit)
}
if (hasTenAdd) {
const tenAddPlace = Math.round(Math.log10(tenAdd!))
// If the +10^k lands above the immediate next place, we must have rippled through 9s.
// Alternatively, multiple negatives (>=2) is also a strong signal of a cascade.
const cascades = tenAddPlace > place + 1 || negatives.length >= 2
return decisionForTenComplement(currentDigit, digit, cascades)
}
return [{
@@ -744,7 +748,7 @@ function generateInstructionFromTerm(term: string, stepIndex: number, isCompleme
} else if (value >= 6 && value <= 9) {
const e = value - 5
return `deactivate heaven bead and remove ${e} earth bead${e > 1 ? 's' : ''}`
} else if (isPowerOfTen(value)) {
} else if (isPowerOfTenGE10(value)) {
const place = Math.round(Math.log10(value))
return `remove 1 from ${getPlaceName(place)}`
} else if (value >= 10) {
@@ -767,7 +771,7 @@ function generateInstructionFromTerm(term: string, stepIndex: number, isCompleme
} else if (value >= 6 && value <= 9) {
const earthBeads = value - 5
return `activate heaven bead and add ${earthBeads} earth beads`
} else if (isPowerOfTen(value)) {
} else if (isPowerOfTenGE10(value)) {
const place = Math.round(Math.log10(value))
return `add 1 to ${getPlaceName(place)}`
} else if (value >= 10) {
@@ -874,11 +878,12 @@ function calculateStepBeadMovements(
// Stabilize movement ordering for consistent UI animations
// Priority: higher place → heaven beads → activations first
movements.sort((a, b) => (
b.placeValue - a.placeValue || // Higher place first (tens before ones)
(a.beadType === 'heaven' ? -1 : 1) - (b.beadType === 'heaven' ? -1 : 1) || // Heaven before earth
(a.direction === 'activate' ? -1 : 1) - (b.direction === 'activate' ? -1 : 1) // Activate before deactivate
))
movements.sort((a, b) => {
if (a.placeValue !== b.placeValue) return b.placeValue - a.placeValue
if (a.beadType !== b.beadType) return a.beadType === 'heaven' ? -1 : 1
if (a.direction !== b.direction) return a.direction === 'activate' ? -1 : 1
return 0
})
// Reassign order indices after sorting
movements.forEach((movement, index) => {
@@ -924,9 +929,7 @@ function generateStepInstruction(
.sort((a, b) => b - a) // Pedagogical order: highest place first
.forEach(place => {
const placeName = place === 0 ? 'ones' :
place === 1 ? 'tens' :
place === 2 ? 'hundreds' : `place ${place}`
const placeName = getPlaceName(place)
const placeData = byPlace[place]