From 00535107835551cd660b1bd523d17126b9e7f6d0 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Thu, 25 Sep 2025 11:04:07 -0500 Subject: [PATCH] feat: add pedagogical segments for contextual learning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/utils/unifiedStepGenerator.ts | 317 +++++++++++++++++++-- 1 file changed, 298 insertions(+), 19 deletions(-) diff --git a/apps/web/src/utils/unifiedStepGenerator.ts b/apps/web/src/utils/unifiedStepGenerator.ts index e8957f48..06c94adb 100644 --- a/apps/web/src/utils/unifiedStepGenerator.ts +++ b/apps/web/src/utils/unifiedStepGenerator.ts @@ -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 − (5−3)"] +} + +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 non‑9 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 ≥ 10−d = ${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 non‑9 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 -} /**