feat: implement provenance system for pedagogical term tracking
Add comprehensive provenance tracking to link mathematical terms back to their source digits in addends. This enables enhanced tooltips that clearly show students which digit expansion corresponds to which part of the original problem. Core changes: - Add TermProvenance interface with source digit tracking (rhs, rhsDigit, rhsPlace, etc.) - Add EquationAnchors interface for UI digit highlighting support - Integrate provenance data through tutorial context instead of prop drilling - Add buildEquationAnchors() for character position mapping - Include groupId for complement operations that share source digits This foundational system enables pedagogical tooltips to show "Add the tens digit — 2 tens (20) from addend 25" instead of generic "Direct Add — tens". 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { createContext, useContext, useReducer, useRef, useState, useCallback, useMemo } from 'react'
|
||||
import { Tutorial, TutorialEvent, UIState, TutorialStep } from '../../types/tutorial'
|
||||
import { generateUnifiedInstructionSequence } from '../../utils/unifiedStepGenerator'
|
||||
import { generateUnifiedInstructionSequence, type UnifiedStepData } from '../../utils/unifiedStepGenerator'
|
||||
|
||||
// Exact same interfaces from TutorialPlayer.tsx
|
||||
interface TutorialPlayerState {
|
||||
@@ -140,6 +140,7 @@ interface TutorialContextType {
|
||||
currentStep: TutorialStep
|
||||
expectedSteps: ExpectedStep[]
|
||||
fullDecomposition: string
|
||||
unifiedSteps: UnifiedStepData[] // NEW: Add unified steps with provenance
|
||||
customStyles: any
|
||||
|
||||
// Action functions
|
||||
@@ -231,7 +232,7 @@ export function TutorialProvider({
|
||||
// Current step and computed values
|
||||
const currentStep = tutorial.steps[state.currentStepIndex]
|
||||
|
||||
const { expectedSteps, fullDecomposition } = useMemo(() => {
|
||||
const { expectedSteps, fullDecomposition, unifiedSteps } = useMemo(() => {
|
||||
try {
|
||||
const unifiedSequence = generateUnifiedInstructionSequence(currentStep.startValue, currentStep.targetValue)
|
||||
|
||||
@@ -248,11 +249,12 @@ export function TutorialProvider({
|
||||
|
||||
return {
|
||||
expectedSteps: mappedSteps,
|
||||
fullDecomposition: unifiedSequence.fullDecomposition
|
||||
fullDecomposition: unifiedSequence.fullDecomposition,
|
||||
unifiedSteps: unifiedSequence.steps // NEW: Include raw steps with provenance
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate unified sequence:', error)
|
||||
return { expectedSteps: [], fullDecomposition: '' }
|
||||
return { expectedSteps: [], fullDecomposition: '', unifiedSteps: [] }
|
||||
}
|
||||
}, [currentStep.startValue, currentStep.targetValue])
|
||||
|
||||
@@ -468,6 +470,7 @@ export function TutorialProvider({
|
||||
currentStep,
|
||||
expectedSteps,
|
||||
fullDecomposition,
|
||||
unifiedSteps,
|
||||
customStyles,
|
||||
|
||||
// Action functions
|
||||
|
||||
@@ -254,7 +254,7 @@ function TutorialPlayerContent({
|
||||
}
|
||||
|
||||
// Define the static expected steps using our unified step generator
|
||||
const { expectedSteps, fullDecomposition, isMeaningfulDecomposition, pedagogicalSegments, termPositions } = useMemo(() => {
|
||||
const { expectedSteps, fullDecomposition, isMeaningfulDecomposition, pedagogicalSegments, termPositions, unifiedSteps } = useMemo(() => {
|
||||
try {
|
||||
const unifiedSequence = generateUnifiedInstructionSequence(currentStep.startValue, currentStep.targetValue)
|
||||
|
||||
@@ -277,7 +277,8 @@ function TutorialPlayerContent({
|
||||
fullDecomposition: unifiedSequence.fullDecomposition,
|
||||
isMeaningfulDecomposition: unifiedSequence.isMeaningfulDecomposition,
|
||||
pedagogicalSegments: unifiedSequence.segments,
|
||||
termPositions: positions
|
||||
termPositions: positions,
|
||||
unifiedSteps: unifiedSequence.steps // NEW: Include the raw unified steps with provenance
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -285,7 +286,8 @@ function TutorialPlayerContent({
|
||||
fullDecomposition: '',
|
||||
isMeaningfulDecomposition: false,
|
||||
pedagogicalSegments: [],
|
||||
termPositions: []
|
||||
termPositions: [],
|
||||
unifiedSteps: [] // NEW: Also add empty array for error case
|
||||
}
|
||||
}
|
||||
}, [currentStep.startValue, currentStep.targetValue])
|
||||
|
||||
@@ -58,6 +58,16 @@ export interface PedagogicalSegment {
|
||||
readable: SegmentReadable
|
||||
}
|
||||
|
||||
export interface TermProvenance {
|
||||
rhs: number; // the addend (difference), e.g., 25
|
||||
rhsDigit: number; // e.g., 2 (for tens), 5 (for ones)
|
||||
rhsPlace: number; // 1=tens, 0=ones, etc.
|
||||
rhsPlaceName: string; // "tens"
|
||||
rhsDigitIndex: number; // index of the digit in the addend string (for highlighting)
|
||||
rhsValue: number; // digit * 10^place (e.g., 20)
|
||||
groupId?: string; // same id for a complement group (e.g., +100 -90 -5)
|
||||
}
|
||||
|
||||
export interface UnifiedStepData {
|
||||
stepIndex: number
|
||||
|
||||
@@ -81,6 +91,14 @@ export interface UnifiedStepData {
|
||||
|
||||
/** Link to pedagogy segment this step belongs to */
|
||||
segmentId?: string
|
||||
|
||||
/** NEW: Provenance linking this term to its source digit in the addend */
|
||||
provenance?: TermProvenance
|
||||
}
|
||||
|
||||
export interface EquationAnchors {
|
||||
differenceText: string; // "25"
|
||||
rhsDigitPositions: Array<{ digitIndex: number; startIndex: number; endIndex: number }>;
|
||||
}
|
||||
|
||||
export interface UnifiedInstructionSequence {
|
||||
@@ -102,6 +120,9 @@ export interface UnifiedInstructionSequence {
|
||||
schemaVersion?: '1' | '2'
|
||||
/** NEW: High-level "chapters" that explain the why */
|
||||
segments: PedagogicalSegment[]
|
||||
|
||||
/** NEW: Character positions for highlighting addend digits */
|
||||
equationAnchors?: EquationAnchors
|
||||
}
|
||||
|
||||
// Internal draft interface for building segments
|
||||
@@ -494,7 +515,7 @@ export function generateUnifiedInstructionSequence(
|
||||
|
||||
// Step 1: Generate pedagogical decomposition terms and segment plan
|
||||
const startState = toState(startValue)
|
||||
const { terms: decompositionTerms, segmentsPlan } = generateDecompositionTerms(startValue, targetValue, toState)
|
||||
const { terms: decompositionTerms, segmentsPlan, decompositionSteps } = generateDecompositionTerms(startValue, targetValue, toState)
|
||||
|
||||
// Step 3: Generate unified steps - each step computes ALL aspects simultaneously
|
||||
const steps: UnifiedStepData[] = []
|
||||
@@ -547,7 +568,8 @@ export function generateUnifiedInstructionSequence(
|
||||
expectedState: newState,
|
||||
beadMovements: stepBeadMovements,
|
||||
isValid: validation.isValid,
|
||||
validationIssues: validation.issues
|
||||
validationIssues: validation.issues,
|
||||
provenance: decompositionSteps[stepIndex]?.provenance
|
||||
}
|
||||
|
||||
steps.push(stepData)
|
||||
@@ -579,6 +601,9 @@ export function generateUnifiedInstructionSequence(
|
||||
// Step 7: Build segments using step positions (exact indices, robust)
|
||||
const segments = buildSegmentsWithPositions(segmentsPlan, fullDecomposition, steps)
|
||||
|
||||
// Step 8: Build equation anchors for addend digit highlighting
|
||||
const equationAnchors = buildEquationAnchors(startValue, targetValue, fullDecomposition)
|
||||
|
||||
const result = {
|
||||
schemaVersion: '2' as const,
|
||||
fullDecomposition,
|
||||
@@ -587,7 +612,8 @@ export function generateUnifiedInstructionSequence(
|
||||
segments,
|
||||
startValue,
|
||||
targetValue,
|
||||
totalSteps: steps.length
|
||||
totalSteps: steps.length,
|
||||
equationAnchors
|
||||
}
|
||||
|
||||
// Development-time invariant checks
|
||||
@@ -610,6 +636,7 @@ interface DecompositionStep {
|
||||
operation: string // The mathematical term like "7", "(10 - 3)", etc.
|
||||
description: string // What this step does pedagogically
|
||||
targetValue: number // Expected value after this step
|
||||
provenance?: TermProvenance // NEW: Link to source digit
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -620,9 +647,9 @@ function generateDecompositionTerms(
|
||||
startValue: number,
|
||||
targetValue: number,
|
||||
toState: (n: number) => AbacusState
|
||||
): { terms: string[]; segmentsPlan: SegmentDraft[] } {
|
||||
): { terms: string[]; segmentsPlan: SegmentDraft[]; decompositionSteps: DecompositionStep[] } {
|
||||
const addend = targetValue - startValue
|
||||
if (addend === 0) return { terms: [], segmentsPlan: [] }
|
||||
if (addend === 0) return { terms: [], segmentsPlan: [], decompositionSteps: [] }
|
||||
if (addend < 0) {
|
||||
// TODO: Handle subtraction in separate sprint
|
||||
throw new Error('Subtraction not implemented yet')
|
||||
@@ -651,6 +678,16 @@ function generateDecompositionTerms(
|
||||
// DEBUG: Log the processing for troubleshooting
|
||||
// console.log(`Processing place ${placeValue}: digit=${digit}, current=${currentDigitAtPlace}, sum=${currentDigitAtPlace + digit}`)
|
||||
|
||||
// Create base provenance for this digit
|
||||
const baseProvenance: TermProvenance = {
|
||||
rhs: addend,
|
||||
rhsDigit: digit,
|
||||
rhsPlace: placeValue,
|
||||
rhsPlaceName: getPlaceName(placeValue),
|
||||
rhsDigitIndex: digitIndex,
|
||||
rhsValue: digit * Math.pow(10, placeValue)
|
||||
}
|
||||
|
||||
// Apply the pedagogical algorithm decision tree
|
||||
const stepResult = processDigitAtPlace(
|
||||
digit,
|
||||
@@ -658,7 +695,8 @@ function generateDecompositionTerms(
|
||||
currentDigitAtPlace,
|
||||
currentState,
|
||||
addend, // Pass the full addend to determine if it's multi-place
|
||||
toState // Pass consistent state converter
|
||||
toState, // Pass consistent state converter
|
||||
baseProvenance // NEW: Pass provenance info
|
||||
)
|
||||
|
||||
const segmentId = `place-${placeValue}-digit-${digit}`
|
||||
@@ -709,7 +747,7 @@ function generateDecompositionTerms(
|
||||
|
||||
// Convert steps to string terms for compatibility
|
||||
const terms = steps.map(step => step.operation)
|
||||
return { terms, segmentsPlan }
|
||||
return { terms, segmentsPlan, decompositionSteps: steps }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -721,7 +759,8 @@ function processDigitAtPlace(
|
||||
currentDigitAtPlace: number,
|
||||
currentState: AbacusState,
|
||||
addend: number,
|
||||
toState: (n: number) => AbacusState
|
||||
toState: (n: number) => AbacusState,
|
||||
baseProvenance: TermProvenance
|
||||
): { steps: DecompositionStep[], newValue: number, newState: AbacusState } {
|
||||
|
||||
const a = currentDigitAtPlace
|
||||
@@ -730,10 +769,10 @@ function processDigitAtPlace(
|
||||
// Decision: Direct addition vs 10's complement
|
||||
if (a + d <= 9) {
|
||||
// Case A: Direct addition at this place
|
||||
return processDirectAddition(d, placeValue, currentState, addend, toState)
|
||||
return processDirectAddition(d, placeValue, currentState, addend, toState, baseProvenance)
|
||||
} else {
|
||||
// Case B: 10's complement required
|
||||
return processTensComplement(d, placeValue, currentState, toState)
|
||||
return processTensComplement(d, placeValue, currentState, toState, baseProvenance)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -745,7 +784,8 @@ function processDirectAddition(
|
||||
placeValue: number,
|
||||
currentState: AbacusState,
|
||||
addend: number,
|
||||
toState: (n: number) => AbacusState
|
||||
toState: (n: number) => AbacusState,
|
||||
baseProvenance: TermProvenance
|
||||
): { steps: DecompositionStep[], newValue: number, newState: AbacusState } {
|
||||
|
||||
const placeState = currentState[placeValue] || { heavenActive: false, earthActive: 0 }
|
||||
@@ -760,7 +800,8 @@ function processDirectAddition(
|
||||
steps.push({
|
||||
operation: (digit * Math.pow(10, placeValue)).toString(),
|
||||
description: `Add ${digit} earth bead${digit > 1 ? 's' : ''} at place ${placeValue}`,
|
||||
targetValue: 0 // Will be calculated later
|
||||
targetValue: 0, // Will be calculated later
|
||||
provenance: baseProvenance
|
||||
})
|
||||
newState[placeValue] = {
|
||||
...placeState,
|
||||
@@ -769,6 +810,7 @@ function processDirectAddition(
|
||||
} else if (!placeState.heavenActive) {
|
||||
// Use 5's complement: digit = (5 - (5 - digit)) when pedagogically valuable
|
||||
const complement = 5 - digit
|
||||
const groupId = `5comp-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}`
|
||||
|
||||
// Always show five-complement pedagogy as separate steps
|
||||
const fiveValue = 5 * Math.pow(10, placeValue)
|
||||
@@ -777,13 +819,15 @@ function processDirectAddition(
|
||||
steps.push({
|
||||
operation: fiveValue.toString(),
|
||||
description: `Add heaven bead at place ${placeValue}`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: { ...baseProvenance, groupId }
|
||||
})
|
||||
|
||||
steps.push({
|
||||
operation: `-${subtractValue}`,
|
||||
description: `Remove ${complement} earth beads at place ${placeValue}`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: { ...baseProvenance, groupId }
|
||||
})
|
||||
|
||||
newState[placeValue] = {
|
||||
@@ -801,14 +845,16 @@ function processDirectAddition(
|
||||
steps.push({
|
||||
operation: fiveValue.toString(),
|
||||
description: `Add heaven bead at place ${placeValue}`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: baseProvenance
|
||||
})
|
||||
|
||||
if (earthBeadsNeeded > 0) {
|
||||
steps.push({
|
||||
operation: remainderValue.toString(),
|
||||
description: `Add ${earthBeadsNeeded} earth beads at place ${placeValue}`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: baseProvenance
|
||||
})
|
||||
}
|
||||
|
||||
@@ -832,7 +878,8 @@ function processTensComplement(
|
||||
digit: number,
|
||||
placeValue: number,
|
||||
currentState: AbacusState,
|
||||
toState: (n: number) => AbacusState
|
||||
toState: (n: number) => AbacusState,
|
||||
baseProvenance: TermProvenance
|
||||
): { steps: DecompositionStep[], newValue: number, newState: AbacusState } {
|
||||
|
||||
const steps: DecompositionStep[] = []
|
||||
@@ -843,9 +890,11 @@ function processTensComplement(
|
||||
const nextPlaceDigit = getDigitAtPlace(currentValue, placeValue + 1)
|
||||
const requiresCascading = nextPlaceDigit === 9
|
||||
|
||||
const groupId = `10comp-${baseProvenance.rhsPlace}-${baseProvenance.rhsDigit}`
|
||||
|
||||
if (requiresCascading) {
|
||||
// Generate cascading complement terms in parenthesized format
|
||||
const cascadeSteps = generateCascadeComplementSteps(currentValue, placeValue, complementToSubtract)
|
||||
const cascadeSteps = generateCascadeComplementSteps(currentValue, placeValue, complementToSubtract, baseProvenance, groupId)
|
||||
steps.push(...cascadeSteps)
|
||||
} else {
|
||||
// Simple ten-complement: generate separate add/subtract steps
|
||||
@@ -855,13 +904,15 @@ function processTensComplement(
|
||||
steps.push({
|
||||
operation: higherPlaceValue.toString(),
|
||||
description: `Add 1 to ${getPlaceName(placeValue + 1)} (carry)`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: { ...baseProvenance, groupId }
|
||||
})
|
||||
|
||||
steps.push({
|
||||
operation: `-${subtractValue}`,
|
||||
description: `Remove ${complementToSubtract} earth beads (no borrow needed)`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: { ...baseProvenance, groupId }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -878,7 +929,7 @@ function processTensComplement(
|
||||
/**
|
||||
* Generate cascade complement steps as individual terms for UI tracking
|
||||
*/
|
||||
function generateCascadeComplementSteps(currentValue: number, startPlace: number, onesComplement: number): DecompositionStep[] {
|
||||
function generateCascadeComplementSteps(currentValue: number, startPlace: number, onesComplement: number, baseProvenance: TermProvenance, groupId: string): DecompositionStep[] {
|
||||
const steps: DecompositionStep[] = []
|
||||
|
||||
// First, add to the highest non-9 place
|
||||
@@ -893,7 +944,8 @@ function generateCascadeComplementSteps(currentValue: number, startPlace: number
|
||||
steps.push({
|
||||
operation: higherPlaceValue.toString(),
|
||||
description: `Add 1 to ${getPlaceName(checkPlace)} (cascade trigger)`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: { ...baseProvenance, groupId }
|
||||
})
|
||||
|
||||
// Clear all the 9s in between (working downward)
|
||||
@@ -904,7 +956,8 @@ function generateCascadeComplementSteps(currentValue: number, startPlace: number
|
||||
steps.push({
|
||||
operation: `-${clearValue}`,
|
||||
description: `Remove 9 from ${getPlaceName(clearPlace)} (cascade)`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: { ...baseProvenance, groupId }
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -914,7 +967,8 @@ function generateCascadeComplementSteps(currentValue: number, startPlace: number
|
||||
steps.push({
|
||||
operation: `-${onesSubtractValue}`,
|
||||
description: `Remove ${onesComplement} earth beads (ten's complement)`,
|
||||
targetValue: 0
|
||||
targetValue: 0,
|
||||
provenance: { ...baseProvenance, groupId }
|
||||
})
|
||||
|
||||
return steps
|
||||
@@ -1446,6 +1500,43 @@ function assertSegments(seq: UnifiedInstructionSequence) {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Build equation anchors for addend digit highlighting
|
||||
*/
|
||||
function buildEquationAnchors(
|
||||
startValue: number,
|
||||
targetValue: number,
|
||||
fullDecomposition: string
|
||||
): EquationAnchors {
|
||||
const addend = targetValue - startValue
|
||||
const addendStr = Math.abs(addend).toString()
|
||||
|
||||
// Find the addend in the left side of the equation
|
||||
// The pattern is typically: "startValue + addend = startValue + [decomposition] = targetValue"
|
||||
const leftSide = `${startValue} + ${addend}`
|
||||
const addendStart = leftSide.indexOf(addend.toString())
|
||||
|
||||
if (addendStart === -1) {
|
||||
// Fallback: return empty positions if we can't find the addend
|
||||
return {
|
||||
differenceText: addendStr,
|
||||
rhsDigitPositions: []
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate positions for each digit in the addend
|
||||
const rhsDigitPositions = Array.from(addendStr).map((digit, index) => ({
|
||||
digitIndex: index,
|
||||
startIndex: addendStart + index,
|
||||
endIndex: addendStart + index + 1
|
||||
}))
|
||||
|
||||
return {
|
||||
differenceText: addendStr,
|
||||
rhsDigitPositions
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a number is a power of 10
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user