feat: add pedagogical segments for contextual learning

Add comprehensive pedagogical segment system that groups low-level
bead movements into learner-friendly "chapters" with goals, reasoning,
and mathematical explanations.

Key additions:
- PedagogicalSegment interface with decision tracking
- SegmentDecision interface for pedagogical reasoning
- Segment building with position mapping
- Enhanced UnifiedStepData with segmentId links
- Schema versioning for safe API evolution

Features:
- Segments track pedagogical rules (Direct, FiveComplement, TenComplement, Cascade)
- Precise term range mapping for UI highlighting
- Complete abacus state snapshots for segment boundaries
- O(1) step-to-segment relationships via segmentId
- Robust position mapping using step indices (no string searching)

Correctness fixes:
- L/U values from segment start state (not computed sum)
- Proper 5-complement vs 10-complement detection with isPowerOfTen
- Complete negative instruction coverage (including 6-9 cases)
- Deterministic segment range calculation
- Fixed segment building order after step position assignment

🤖 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:04:07 -05:00
parent 2e12049d4e
commit 0053510783

View File

@@ -10,6 +10,42 @@ import {
calculateBeadChanges
} from './abacusInstructionGenerator'
export type PedagogicalRule = 'Direct' | 'FiveComplement' | 'TenComplement' | 'Cascade'
export interface SegmentDecision {
/** Short, machine-readable rule fired at this segment */
rule: PedagogicalRule
/** Guard conditions that selected this rule */
conditions: string[] // e.g., ["a+d=6 ≤ 9", "L+d=5 > 4"]
/** Friendly bullets explaining the why */
explanation: string[] // e.g., ["No room for 3 lowers → use +5 (53)"]
}
export interface PedagogicalSegment {
id: string // e.g., "P1-d4-#2"
place: number // P
digit: number // d
a: number // digit currently showing at P before the segment
L: number // lowers down at P
U: 0 | 1 // upper down?
goal: string // "Increase tens by 4 without carry"
plan: SegmentDecision[] // one or more rules (Cascade includes TenComplement+Cascade)
/** Expression for the whole segment, e.g., "40" or "(100 - 90 - 6)" */
expression: string
/** Indices into the flat `steps` array that belong to this segment */
stepIndices: number[]
/** Indices into the decompositionTerms list that belong to this segment */
termIndices: number[]
/** character range inside `fullDecomposition` spanning the expression */
termRange: { startIndex: number; endIndex: number }
/** Segment start→end snapshot (optional but useful for UI tooltips) */
startValue: number
endValue: number
startState: AbacusState
endState: AbacusState
}
export interface UnifiedStepData {
stepIndex: number
@@ -30,6 +66,9 @@ export interface UnifiedStepData {
// Validation
isValid: boolean
validationIssues?: string[]
/** Link to pedagogy segment this step belongs to */
segmentId?: string
}
export interface UnifiedInstructionSequence {
@@ -46,6 +85,207 @@ export interface UnifiedInstructionSequence {
startValue: number
targetValue: number
totalSteps: number
/** NEW: Schema version for compatibility */
schemaVersion?: '1' | '2'
/** NEW: High-level "chapters" that explain the why */
segments: PedagogicalSegment[]
}
// Internal draft interface for building segments
interface SegmentDraft {
id: string
place: number
digit: number
a: number
L: number
U: 0 | 1
plan: SegmentDecision[]
goal: string
/** contiguous indices into steps[] / terms[] for this segment */
stepIndices: number[]
termIndices: number[]
// Value/state snapshots
startValue: number
startState: AbacusState
endValue: number
endState: AbacusState
}
/**
* Helper functions for building segment decisions and explanations
*/
function isPowerOfTen(n: number): boolean {
if (n < 1) return false
return /^10*$/.test(n.toString())
}
function inferGoal(seg: SegmentDraft): string {
const placeName = getPlaceName(seg.place)
switch (seg.plan[0]?.rule) {
case 'Direct':
return `Increase ${placeName} by ${seg.digit} without carry`
case 'FiveComplement':
return `Add ${seg.digit} to ${placeName} using 5's complement`
case 'TenComplement':
return `Add ${seg.digit} to ${placeName} with a carry`
case 'Cascade':
return `Carry through ${placeName}+ to nearest non9 place`
default:
return `Apply operation at ${placeName}`
}
}
function decisionForDirect(a: number, d: number, L: number): SegmentDecision[] {
if (L + d <= 4) {
return [{
rule: 'Direct',
conditions: [`a+d=${a}+${d}=${a+d} ≤ 9`],
explanation: ['Fits inside this place; add earth beads directly.']
}]
} else {
const s = 5 - d
return [{
rule: 'FiveComplement',
conditions: [`a+d=${a}+${d}=${a+d} ≤ 9`, `L+d=${L}+${d}=${L+d} > 4`],
explanation: [
'No room for that many earth beads.',
`Use +5 (5${d}) = +5 ${s}; subtraction is possible because lowers ≥ ${s}.`
]
}]
}
}
function decisionForFiveComplement(a: number, d: number): SegmentDecision[] {
const s = 5 - d
return [{
rule: 'FiveComplement',
conditions: [`a+d=${a}+${d}=${a+d} ≤ 9`, `L+d > 4`],
explanation: [
'No room for that many earth beads.',
`Use +5 (5${d}) = +5 ${s}; subtraction is possible because lowers ≥ ${s}.`
]
}]
}
function decisionForTenComplement(a: number, d: number, nextIs9: boolean): SegmentDecision[] {
const base: SegmentDecision = {
rule: 'TenComplement',
conditions: [`a+d=${a}+${d}=${a+d} ≥ 10`, `a ≥ 10d = ${10-d}`],
explanation: [
'Need a carry to the next higher place.',
`No borrow at this place because a ≥ ${10-d}.`
]
}
if (!nextIs9) return [base]
return [
base,
{
rule: 'Cascade',
conditions: ['next place is 9 ⇒ ripple carry'],
explanation: ['Increment nearest non9 place; clear intervening 9s.']
}
]
}
function formatSegmentExpression(terms: string[]): string {
// single term -> "40"
if (terms.length === 1 && !terms[0].startsWith('-')) return terms[0]
// complement group -> "(pos - n1 - n2 - ...)"
const pos = terms[0]
const negs = terms.slice(1).map(t => t.replace(/^-/, ''))
return `(${pos} - ${negs.join(' - ')})`
}
function formatSegmentGoal(digit: number, placeValue: number): string {
const placeName = getPlaceName(placeValue)
return `Add ${digit} to ${placeName}`
}
function buildSegmentsWithPositions(
segmentsPlan: SegmentDraft[],
fullDecomposition: string,
steps: UnifiedStepData[]
): PedagogicalSegment[] {
return segmentsPlan.map(draft => {
const segmentTerms = draft.stepIndices
.map(i => steps[i]?.mathematicalTerm)
.filter((t): t is string => !!t)
// Range from steps -> exact, no string search
const ranges = draft.stepIndices
.map(i => steps[i]?.termPosition)
.filter((r): r is {startIndex:number; endIndex:number} => !!r)
let start = Math.min(...ranges.map(r => r.startIndex))
let end = Math.max(...ranges.map(r => r.endIndex))
// Optionally include surrounding parentheses for complement groups
if (fullDecomposition[start - 1] === '(' && fullDecomposition[end] === ')') {
start -= 1; end += 1
}
return {
id: draft.id,
place: draft.place,
digit: draft.digit,
a: draft.a,
L: draft.L,
U: draft.U,
goal: draft.goal,
plan: draft.plan,
expression: formatSegmentExpression(segmentTerms),
stepIndices: draft.stepIndices,
termIndices: draft.termIndices,
termRange: { startIndex: start, endIndex: end },
startValue: draft.startValue,
endValue: draft.endValue,
startState: draft.startState,
endState: draft.endState
}
})
}
function determineSegmentDecisions(
digit: number,
place: number,
currentDigit: number,
steps: DecompositionStep[]
): SegmentDecision[] {
const sum = currentDigit + digit
if (steps.length === 1) {
return [{
rule: 'Direct',
conditions: [`a+d=${currentDigit}+${digit}=${sum} ≤ 9`],
explanation: ['Fits in this place; add earth beads directly.']
}]
}
const hasPositive = steps.some(s => !s.operation.startsWith('-'))
const hasNegative = steps.some(s => s.operation.startsWith('-'))
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))
if (hasFiveAdd && !hasTenAdd) {
return decisionForFiveComplement(currentDigit, digit)
}
if (hasTenAdd) {
const nextIs9 = positives.length > 1 // cascade if ripple seen
return decisionForTenComplement(currentDigit, digit, nextIs9)
}
}
return [{
rule: 'Direct',
conditions: [`processing digit ${digit} at ${getPlaceName(place)}`],
explanation: ['Standard operation.']
}]
}
/**
@@ -69,8 +309,8 @@ export function generateUnifiedInstructionSequence(
const targetState = toState(targetValue)
const { additions, removals } = calculateBeadChanges(startState, targetState)
// Step 2: Generate pedagogical decomposition terms based on actual bead movements
const decompositionTerms = generateDecompositionTerms(startValue, targetValue, additions, removals, toState)
// Step 2: Generate pedagogical decomposition terms and segment plan based on actual bead movements
const { terms: decompositionTerms, segmentsPlan } = generateDecompositionTerms(startValue, targetValue, additions, removals, toState)
// Step 3: Generate unified steps - each step computes ALL aspects simultaneously
const steps: UnifiedStepData[] = []
@@ -144,17 +384,23 @@ export function generateUnifiedInstructionSequence(
// Step 5: Determine if this decomposition is meaningful
const isMeaningfulDecomposition = isDecompositionMeaningful(startValue, targetValue, decompositionTerms, fullDecomposition)
// Step 6: Add position information to each step
steps.forEach((step, index) => {
if (termPositions[index]) {
step.termPosition = termPositions[index]
}
// Step 6: Attach term positions and segment ids to steps
steps.forEach((step, idx) => {
if (termPositions[idx]) step.termPosition = termPositions[idx]
})
// (optional) annotate steps with the segment they belong to
segmentsPlan.forEach(seg => seg.stepIndices.forEach(i => { if (steps[i]) steps[i].segmentId = seg.id }))
// Step 7: Build segments using step positions (exact indices, robust)
const segments = buildSegmentsWithPositions(segmentsPlan, fullDecomposition, steps)
return {
schemaVersion: '2' as const,
fullDecomposition,
isMeaningfulDecomposition,
steps,
segments,
startValue,
targetValue,
totalSteps: steps.length
@@ -185,9 +431,9 @@ function generateDecompositionTerms(
additions: BeadHighlight[], // Legacy parameter - not used in new algo
removals: BeadHighlight[], // Legacy parameter - not used in new algo
toState: (n: number) => AbacusState
): string[] {
): { terms: string[]; segmentsPlan: SegmentDraft[] } {
const addend = targetValue - startValue
if (addend === 0) return []
if (addend === 0) return { terms: [], segmentsPlan: [] }
if (addend < 0) {
// TODO: Handle subtraction in separate sprint
throw new Error('Subtraction not implemented yet')
@@ -197,6 +443,7 @@ function generateDecompositionTerms(
let currentState = toState(startValue)
let currentValue = startValue
const steps: DecompositionStep[] = []
const segmentsPlan: SegmentDraft[] = []
// Process addend digit by digit from left to right (highest to lowest place)
const addendStr = addend.toString()
@@ -210,6 +457,7 @@ function generateDecompositionTerms(
// Get current digit at this place value
const currentDigitAtPlace = getDigitAtPlace(currentValue, placeValue)
const startStepCount = steps.length
// DEBUG: Log the processing for troubleshooting
// console.log(`Processing place ${placeValue}: digit=${digit}, current=${currentDigitAtPlace}, sum=${currentDigitAtPlace + digit}`)
@@ -224,14 +472,50 @@ function generateDecompositionTerms(
toState // Pass consistent state converter
)
// Apply the step result to our current state
const segmentId = `place-${placeValue}-digit-${digit}`
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
// Apply the step result
steps.push(...stepResult.steps)
currentValue = stepResult.newValue
currentState = stepResult.newState
const endStepCount = steps.length
const stepIndices = Array.from({ length: endStepCount - startStepCount }, (_, i) => startStepCount + i)
// Decide pedagogy
const plan = determineSegmentDecisions(digit, placeValue, currentDigitAtPlace, stepResult.steps)
const goal = inferGoal({ 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)
}
// Convert steps to string terms for compatibility
return steps.map(step => step.operation)
const terms = steps.map(step => step.operation)
return { terms, segmentsPlan }
}
/**
@@ -457,6 +741,9 @@ function generateInstructionFromTerm(term: string, stepIndex: number, isCompleme
return `remove ${value} earth bead${value > 1 ? 's' : ''}`
} else if (value === 5) {
return 'deactivate heaven bead'
} 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)) {
const place = Math.round(Math.log10(value))
return `remove 1 from ${getPlaceName(place)}`
@@ -946,14 +1233,6 @@ export function buildFullDecompositionWithPositions(
/**
* Check if a number is a power of 10
*/
function isPowerOfTen(num: number): boolean {
if (num < 10) return false
while (num > 1) {
if (num % 10 !== 0) return false
num = num / 10
}
return true
}
/**