feat(practice): add subtraction support to problem generator

- Add subtraction problem generation alongside addition
- Generator now uses signed terms (negative = subtraction)
- Update analyzeRequiredSkills to handle mixed operations
- Remove dead generateSkillTrace function (replaced by provenance)
- Add ProblemGeneratorAudit story for debugging skill analysis
- Display subtraction terms in red with proper +/- signs in audit UI

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-10 08:06:34 -06:00
parent a3e79dac74
commit 4f7a9d76cd
3 changed files with 1215 additions and 48 deletions

View File

@ -134,11 +134,14 @@
"Bash(mcp__sqlite__describe_table:*)",
"Bash(ls:*)",
"Bash(mcp__sqlite__list_tables:*)",
"Bash(mcp__sqlite__read_query:*)"
"Bash(mcp__sqlite__read_query:*)",
"Bash(gh api:*)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": ["sqlite"]
"enabledMcpjsonServers": [
"sqlite"
]
}

View File

@ -0,0 +1,920 @@
/**
* Problem Generator Audit Story
*
* This story allows you to:
* 1. Select skills (same structure as the practice app)
* 2. Generate problems using the EXACT same code path as the practice app
* 3. View debug traces for copy/paste debugging
* 4. Regenerate problems to see different outputs
*/
import type { Meta, StoryObj } from '@storybook/react'
import { useCallback, useState } from 'react'
import { createEmptySkillSet, type SkillSet } from '@/types/tutorial'
import {
analyzeRequiredSkills,
type GenerationTrace,
generateSingleProblem,
type ProblemConstraints,
} from '@/utils/problemGenerator'
import { css } from '../../../styled-system/css'
const meta: Meta = {
title: 'Practice/Problem Generator Audit',
parameters: {
layout: 'fullscreen',
},
}
export default meta
/** Constraints as displayed in the debug trace (different from generator's ProblemConstraints) */
interface DisplayConstraints {
requiredSkills: SkillSet
digitRange: { min: number; max: number }
termCount: { min: number; max: number }
}
interface DebugTrace {
timestamp: string
input: {
constraints: DisplayConstraints
skillSetSnapshot: SkillSet
}
output: {
terms: number[]
answer: number
skillsRequired: string[]
}
analysis: {
actualSkillsFromAnalyzer: string[]
skillsMatch: boolean
mismatchedSkills: string[]
}
stepByStepTrace: GenerationTrace
/** True if the generator failed to produce a problem */
generationFailed?: boolean
}
// Skill checkbox group component
function SkillGroup({
title,
skills,
category,
skillSet,
onToggle,
}: {
title: string
skills: { key: string; label: string }[]
category: keyof SkillSet
skillSet: SkillSet
onToggle: (category: keyof SkillSet, key: string) => void
}) {
const categorySkills = skillSet[category] as Record<string, boolean>
return (
<div
className={css({
p: '1rem',
bg: 'gray.50',
borderRadius: '8px',
mb: '1rem',
})}
>
<h3
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
mb: '0.5rem',
color: 'gray.700',
})}
>
{title}
</h3>
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
})}
>
{skills.map(({ key, label }) => (
<label
key={key}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
fontSize: '0.75rem',
cursor: 'pointer',
bg: categorySkills[key] ? 'blue.100' : 'white',
px: '0.5rem',
py: '0.25rem',
borderRadius: '4px',
border: '1px solid',
borderColor: categorySkills[key] ? 'blue.300' : 'gray.300',
transition: 'all 0.1s',
_hover: { borderColor: 'blue.400' },
})}
>
<input
type="checkbox"
checked={categorySkills[key] || false}
onChange={() => onToggle(category, key)}
className={css({ cursor: 'pointer' })}
/>
{label}
</label>
))}
</div>
</div>
)
}
// Main audit component
function ProblemGeneratorAuditUI() {
// Skill set state - starts with basic addition enabled
const [skillSet, setSkillSet] = useState<SkillSet>(() => {
const base = createEmptySkillSet()
base.basic.directAddition = true
base.basic.heavenBead = true
return base
})
// Constraints state
const [maxDigits, setMaxDigits] = useState(1)
const [maxTerms, setMaxTerms] = useState(5)
// Results state
const [debugTraces, setDebugTraces] = useState<DebugTrace[]>([])
const [generationCount, setGenerationCount] = useState(0)
// Toggle a skill
const toggleSkill = useCallback((category: keyof SkillSet, key: string) => {
setSkillSet((prev) => ({
...prev,
[category]: {
...prev[category],
[key]: !(prev[category] as Record<string, boolean>)[key],
},
}))
}, [])
// Generate a problem using the EXACT same code path as the practice app
const generateProblem = useCallback(() => {
const maxValue = 10 ** maxDigits - 1
const constraints: ProblemConstraints = {
numberRange: { min: 1, max: maxValue },
maxTerms,
problemCount: 1,
}
// Call generateSingleProblem directly to get the generation trace (provenance)
const result = generateSingleProblem(constraints, skillSet)
if (!result) {
// Generation failed - create a trace showing the failure
const failureTrace: DebugTrace = {
timestamp: new Date().toISOString(),
input: {
constraints: {
requiredSkills: skillSet,
digitRange: { min: 1, max: maxDigits },
termCount: { min: 3, max: maxTerms },
},
skillSetSnapshot: JSON.parse(JSON.stringify(skillSet)),
},
output: {
terms: [],
answer: 0,
skillsRequired: [],
},
analysis: {
actualSkillsFromAnalyzer: [],
skillsMatch: true,
mismatchedSkills: [],
},
stepByStepTrace: { terms: [], answer: 0, steps: [], allSkills: [] },
generationFailed: true,
}
setDebugTraces((prev) => [failureTrace, ...prev])
setGenerationCount((prev) => prev + 1)
return
}
// Get the provenance trace directly from the generator
const stepByStepTrace = result.generationTrace!
// Run the analyzer independently to verify (this is the comparison)
const actualSkillsFromAnalyzer = analyzeRequiredSkills(result.terms, result.answer)
// Check for mismatches between generator's provenance and independent analyzer
const resultSkillsSet = new Set(result.requiredSkills)
const analyzerSkillsSet = new Set(actualSkillsFromAnalyzer)
const mismatchedSkills = [
...result.requiredSkills.filter((s) => !analyzerSkillsSet.has(s)),
...actualSkillsFromAnalyzer.filter((s) => !resultSkillsSet.has(s)),
]
const trace: DebugTrace = {
timestamp: new Date().toISOString(),
input: {
constraints: {
requiredSkills: skillSet,
digitRange: { min: 1, max: maxDigits },
termCount: { min: 3, max: maxTerms },
},
skillSetSnapshot: JSON.parse(JSON.stringify(skillSet)),
},
output: {
terms: result.terms,
answer: result.answer,
skillsRequired: result.requiredSkills,
},
analysis: {
actualSkillsFromAnalyzer,
skillsMatch:
result.requiredSkills.length === actualSkillsFromAnalyzer.length &&
result.requiredSkills.every((s) => analyzerSkillsSet.has(s)),
mismatchedSkills,
},
stepByStepTrace,
}
setDebugTraces((prev) => [trace, ...prev])
setGenerationCount((prev) => prev + 1)
}, [skillSet, maxDigits, maxTerms])
// Clear traces
const clearTraces = useCallback(() => {
setDebugTraces([])
}, [])
// Format step-by-step trace for display
const formatStepByStepTrace = useCallback((trace: GenerationTrace): string => {
let output = '### Step-by-Step Skill Analysis\n\n'
output += '| Step | Operation | Skills Used | Explanation |\n'
output += '|------|-----------|-------------|-------------|\n'
for (const step of trace.steps) {
const skills = step.skillsUsed.length > 0 ? step.skillsUsed.join(', ') : '(none)'
output += `| ${step.stepNumber} | ${step.operation} | ${skills} | ${step.explanation} |\n`
}
return output
}, [])
// Copy trace to clipboard
const copyTrace = useCallback(
(trace: DebugTrace) => {
const text = `## Problem Generator Debug Trace
**Timestamp:** ${trace.timestamp}
### Input Constraints
\`\`\`json
${JSON.stringify(trace.input.constraints, null, 2)}
\`\`\`
### Skill Set Snapshot
\`\`\`json
${JSON.stringify(trace.input.skillSetSnapshot, null, 2)}
\`\`\`
### Output
- **Terms:** ${trace.output.terms.join(' + ')} = ${trace.output.answer}
- **Skills Required:** ${trace.output.skillsRequired.join(', ')}
${formatStepByStepTrace(trace.stepByStepTrace)}
### Analysis
- **Analyzer Skills:** ${trace.analysis.actualSkillsFromAnalyzer.join(', ')}
- **Skills Match:** ${trace.analysis.skillsMatch ? '✅ Yes' : '❌ No'}
${trace.analysis.mismatchedSkills.length > 0 ? `- **Mismatched Skills:** ${trace.analysis.mismatchedSkills.join(', ')}` : ''}
`
navigator.clipboard.writeText(text)
},
[formatStepByStepTrace]
)
// Skill definitions for the UI
const basicSkills = [
{ key: 'directAddition', label: 'Direct Add (1-4)' },
{ key: 'heavenBead', label: 'Heaven Bead (5)' },
{ key: 'simpleCombinations', label: 'Simple Combos (6-9)' },
{ key: 'directSubtraction', label: 'Direct Sub' },
{ key: 'heavenBeadSubtraction', label: 'Heaven Bead Sub' },
{ key: 'simpleCombinationsSub', label: 'Simple Combos Sub' },
]
const fiveComplementsAdd = [
{ key: '4=5-1', label: '+4 = +5-1' },
{ key: '3=5-2', label: '+3 = +5-2' },
{ key: '2=5-3', label: '+2 = +5-3' },
{ key: '1=5-4', label: '+1 = +5-4' },
]
const tenComplementsAdd = [
{ key: '9=10-1', label: '+9 = +10-1' },
{ key: '8=10-2', label: '+8 = +10-2' },
{ key: '7=10-3', label: '+7 = +10-3' },
{ key: '6=10-4', label: '+6 = +10-4' },
{ key: '5=10-5', label: '+5 = +10-5' },
{ key: '4=10-6', label: '+4 = +10-6' },
{ key: '3=10-7', label: '+3 = +10-7' },
{ key: '2=10-8', label: '+2 = +10-8' },
{ key: '1=10-9', label: '+1 = +10-9' },
]
const fiveComplementsSub = [
{ key: '-4=-5+1', label: '-4 = -5+1' },
{ key: '-3=-5+2', label: '-3 = -5+2' },
{ key: '-2=-5+3', label: '-2 = -5+3' },
{ key: '-1=-5+4', label: '-1 = -5+4' },
]
const tenComplementsSub = [
{ key: '-9=+1-10', label: '-9 = +1-10' },
{ key: '-8=+2-10', label: '-8 = +2-10' },
{ key: '-7=+3-10', label: '-7 = +3-10' },
{ key: '-6=+4-10', label: '-6 = +4-10' },
{ key: '-5=+5-10', label: '-5 = +5-10' },
{ key: '-4=+6-10', label: '-4 = +6-10' },
{ key: '-3=+7-10', label: '-3 = +7-10' },
{ key: '-2=+8-10', label: '-2 = +8-10' },
{ key: '-1=+9-10', label: '-1 = +9-10' },
]
return (
<div
data-component="problem-generator-audit"
className={css({
display: 'flex',
h: '100vh',
bg: 'gray.100',
})}
>
{/* Left Panel: Controls */}
<div
className={css({
w: '400px',
bg: 'white',
borderRight: '1px solid',
borderColor: 'gray.200',
overflow: 'auto',
p: '1rem',
})}
>
<h1
className={css({
fontSize: '1.25rem',
fontWeight: 'bold',
mb: '1rem',
})}
>
Problem Generator Audit
</h1>
{/* Constraints */}
<div
className={css({
mb: '1rem',
p: '1rem',
bg: 'blue.50',
borderRadius: '8px',
})}
>
<h2
className={css({
fontSize: '0.875rem',
fontWeight: 'bold',
mb: '0.75rem',
})}
>
Constraints
</h2>
<div className={css({ display: 'flex', gap: '1rem' })}>
<label className={css({ flex: 1 })}>
<span className={css({ fontSize: '0.75rem', color: 'gray.600' })}>Max Digits</span>
<select
value={maxDigits}
onChange={(e) => setMaxDigits(Number(e.target.value))}
className={css({
w: '100%',
p: '0.5rem',
borderRadius: '4px',
border: '1px solid',
borderColor: 'gray.300',
})}
>
<option value={1}>1 (1-9)</option>
<option value={2}>2 (1-99)</option>
<option value={3}>3 (1-999)</option>
</select>
</label>
<label className={css({ flex: 1 })}>
<span className={css({ fontSize: '0.75rem', color: 'gray.600' })}>Max Terms</span>
<select
value={maxTerms}
onChange={(e) => setMaxTerms(Number(e.target.value))}
className={css({
w: '100%',
p: '0.5rem',
borderRadius: '4px',
border: '1px solid',
borderColor: 'gray.300',
})}
>
{[3, 4, 5, 6, 7, 8].map((n) => (
<option key={n} value={n}>
{n} terms
</option>
))}
</select>
</label>
</div>
</div>
{/* Skills */}
<SkillGroup
title="Basic Skills"
skills={basicSkills}
category="basic"
skillSet={skillSet}
onToggle={toggleSkill}
/>
<SkillGroup
title="Five Complements (Addition)"
skills={fiveComplementsAdd}
category="fiveComplements"
skillSet={skillSet}
onToggle={toggleSkill}
/>
<SkillGroup
title="Ten Complements (Addition)"
skills={tenComplementsAdd}
category="tenComplements"
skillSet={skillSet}
onToggle={toggleSkill}
/>
<SkillGroup
title="Five Complements (Subtraction)"
skills={fiveComplementsSub}
category="fiveComplementsSub"
skillSet={skillSet}
onToggle={toggleSkill}
/>
<SkillGroup
title="Ten Complements (Subtraction)"
skills={tenComplementsSub}
category="tenComplementsSub"
skillSet={skillSet}
onToggle={toggleSkill}
/>
{/* Actions */}
<div
className={css({
position: 'sticky',
bottom: 0,
bg: 'white',
py: '1rem',
borderTop: '1px solid',
borderColor: 'gray.200',
display: 'flex',
gap: '0.5rem',
})}
>
<button
onClick={generateProblem}
className={css({
flex: 1,
bg: 'blue.600',
color: 'white',
py: '0.75rem',
px: '1rem',
borderRadius: '8px',
fontWeight: 'bold',
cursor: 'pointer',
_hover: { bg: 'blue.700' },
})}
>
Generate Problem
</button>
<button
onClick={clearTraces}
className={css({
bg: 'gray.200',
color: 'gray.700',
py: '0.75rem',
px: '1rem',
borderRadius: '8px',
cursor: 'pointer',
_hover: { bg: 'gray.300' },
})}
>
Clear
</button>
</div>
</div>
{/* Right Panel: Results */}
<div
className={css({
flex: 1,
overflow: 'auto',
p: '1rem',
})}
>
<div
className={css({
mb: '1rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
})}
>
<h2
className={css({
fontSize: '1rem',
fontWeight: 'bold',
})}
>
Debug Traces ({debugTraces.length})
</h2>
<span className={css({ fontSize: '0.875rem', color: 'gray.500' })}>
Total generations: {generationCount}
</span>
</div>
{debugTraces.length === 0 ? (
<div
className={css({
textAlign: 'center',
py: '4rem',
color: 'gray.500',
})}
>
<p>Click "Generate Problem" to see debug traces</p>
<p className={css({ fontSize: '0.875rem', mt: '0.5rem' })}>
Each trace can be copied and pasted for debugging
</p>
</div>
) : (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '1rem' })}>
{debugTraces.map((trace, index) => (
<div
key={trace.timestamp}
className={css({
bg: 'white',
borderRadius: '8px',
border: '1px solid',
borderColor: trace.analysis.skillsMatch ? 'gray.200' : 'red.300',
overflow: 'hidden',
})}
>
{/* Header */}
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
p: '0.75rem 1rem',
bg: trace.analysis.skillsMatch ? 'gray.50' : 'red.50',
borderBottom: '1px solid',
borderColor: trace.analysis.skillsMatch ? 'gray.200' : 'red.200',
})}
>
<span className={css({ fontSize: '0.75rem', color: 'gray.500' })}>
#{debugTraces.length - index} {new Date(trace.timestamp).toLocaleTimeString()}
</span>
<button
onClick={() => copyTrace(trace)}
className={css({
fontSize: '0.75rem',
bg: 'blue.100',
color: 'blue.700',
px: '0.5rem',
py: '0.25rem',
borderRadius: '4px',
cursor: 'pointer',
_hover: { bg: 'blue.200' },
})}
>
Copy Debug Trace
</button>
</div>
{/* Problem Display */}
<div className={css({ p: '1rem' })}>
<div
className={css({
display: 'flex',
gap: '2rem',
alignItems: 'flex-start',
})}
>
{/* Vertical Problem with Skills per Term */}
<div
className={css({
fontFamily: 'monospace',
fontSize: '1rem',
bg: 'gray.100',
p: '1rem',
borderRadius: '8px',
})}
>
{trace.output.terms.map((term, i) => {
// Find the corresponding step in the trace (step numbers are 1-indexed, first term is step 1)
const step = trace.stepByStepTrace.steps.find((s) => s.stepNumber === i + 1)
return (
<div
key={i}
className={css({
display: 'flex',
alignItems: 'center',
gap: '1rem',
py: '0.25rem',
})}
>
{/* Term value - right aligned with sign */}
<span
className={css({
minW: '60px',
textAlign: 'right',
color: term < 0 ? 'red.600' : 'inherit',
})}
>
{i === 0 ? term : term >= 0 ? `+${term}` : term}
</span>
{/* Running total and skills (not shown for first term) */}
{i === 0 ? (
<span
className={css({
fontSize: '0.75rem',
color: 'gray.500',
})}
>
(start)
</span>
) : step ? (
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
})}
>
{/* Running total */}
<span
className={css({
fontSize: '0.75rem',
color: 'gray.600',
fontWeight: 'bold',
})}
>
= {step.accumulatedAfter}
</span>
{/* Skills used */}
{step.skillsUsed.length > 0 && (
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '0.25rem',
})}
>
{step.skillsUsed.map((skill) => (
<span
key={skill}
className={css({
fontSize: '0.625rem',
bg: 'purple.100',
color: 'purple.800',
px: '0.375rem',
py: '0.125rem',
borderRadius: '4px',
})}
>
{skill}
</span>
))}
</div>
)}
{step.skillsUsed.length === 0 && (
<span
className={css({
fontSize: '0.625rem',
color: 'gray.400',
})}
>
(basic)
</span>
)}
</div>
) : null}
</div>
)
})}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '1rem',
borderTop: '2px solid',
borderColor: 'gray.400',
pt: '0.25rem',
mt: '0.25rem',
})}
>
<span
className={css({
minW: '60px',
textAlign: 'right',
fontWeight: 'bold',
})}
>
{trace.output.answer}
</span>
<span
className={css({
fontSize: '0.75rem',
color: 'gray.600',
})}
>
(final)
</span>
</div>
</div>
{/* Details */}
<div className={css({ flex: 1 })}>
{/* Skills Required */}
<div className={css({ mb: '0.75rem' })}>
<span className={css({ fontSize: '0.75rem', color: 'gray.500' })}>
Skills from generator:
</span>
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' })}>
{trace.output.skillsRequired.map((skill) => (
<span
key={skill}
className={css({
fontSize: '0.75rem',
bg: 'blue.100',
color: 'blue.800',
px: '0.5rem',
py: '0.125rem',
borderRadius: '4px',
})}
>
{skill}
</span>
))}
</div>
</div>
{/* Skills from Analyzer */}
<div className={css({ mb: '0.75rem' })}>
<span className={css({ fontSize: '0.75rem', color: 'gray.500' })}>
Skills from analyzer:
</span>
<div className={css({ display: 'flex', flexWrap: 'wrap', gap: '0.25rem' })}>
{trace.analysis.actualSkillsFromAnalyzer.map((skill) => (
<span
key={skill}
className={css({
fontSize: '0.75rem',
bg: 'green.100',
color: 'green.800',
px: '0.5rem',
py: '0.125rem',
borderRadius: '4px',
})}
>
{skill}
</span>
))}
</div>
</div>
{/* Mismatch Warning */}
{!trace.analysis.skillsMatch && (
<div
className={css({
bg: 'red.100',
color: 'red.800',
p: '0.5rem',
borderRadius: '4px',
fontSize: '0.75rem',
})}
>
Skills mismatch! Mismatched:{' '}
{trace.analysis.mismatchedSkills.join(', ')}
</div>
)}
</div>
</div>
{/* Full Debug Trace - always visible */}
<div
className={css({
mt: '1rem',
p: '0.75rem',
bg: 'gray.900',
borderRadius: '8px',
color: 'gray.100',
})}
>
<div
className={css({
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: '0.5rem',
})}
>
<span
className={css({
fontSize: '0.75rem',
fontWeight: 'bold',
color: 'gray.400',
})}
>
Debug Trace (copy for debugging)
</span>
<button
onClick={() => copyTrace(trace)}
className={css({
fontSize: '0.625rem',
bg: 'gray.700',
color: 'gray.200',
px: '0.5rem',
py: '0.25rem',
borderRadius: '4px',
cursor: 'pointer',
_hover: { bg: 'gray.600' },
})}
>
Copy as Markdown
</button>
</div>
<pre
className={css({
fontSize: '0.625rem',
fontFamily: 'monospace',
overflow: 'auto',
maxH: '500px',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
})}
>
{`## Problem Generator Debug Trace
**Timestamp:** ${trace.timestamp}
### Input Constraints
\`\`\`json
${JSON.stringify(trace.input.constraints, null, 2)}
\`\`\`
### Enabled Skills at Generation Time
\`\`\`json
${JSON.stringify(
Object.fromEntries(
Object.entries(trace.input.skillSetSnapshot).map(([cat, skills]) => [
cat,
Object.fromEntries(
Object.entries(skills as Record<string, boolean>).filter(([, enabled]) => enabled)
),
])
),
null,
2
)}
\`\`\`
### Output
- **Terms:** ${trace.output.terms.join(' + ')} = ${trace.output.answer}
- **Skills Required:** ${trace.output.skillsRequired.join(', ')}
${formatStepByStepTrace(trace.stepByStepTrace)}
### Analysis
- **Analyzer Skills:** ${trace.analysis.actualSkillsFromAnalyzer.join(', ')}
- **Skills Match:** ${trace.analysis.skillsMatch ? '✅ Yes' : '❌ No'}${trace.analysis.mismatchedSkills.length > 0 ? `\n- **Mismatched Skills:** ${trace.analysis.mismatchedSkills.join(', ')}` : ''}`}
</pre>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export const Audit: StoryObj = {
render: () => <ProblemGeneratorAuditUI />,
}

View File

@ -7,6 +7,8 @@ export interface GeneratedProblem {
requiredSkills: string[]
difficulty: 'easy' | 'medium' | 'hard'
explanation?: string
/** Step-by-step trace from the generator showing skills used at each step */
generationTrace?: GenerationTrace
}
export interface ProblemConstraints {
@ -18,24 +20,134 @@ export interface ProblemConstraints {
}
/**
* Analyzes which skills are required during the sequential addition process
* This simulates adding each term one by one to the abacus
* Analyzes which skills are required during sequential computation.
* Handles both addition (positive terms) and subtraction (negative terms).
* This simulates computing each term one by one on the abacus.
*/
export function analyzeRequiredSkills(terms: number[], _finalSum: number): string[] {
const skills: string[] = []
let currentValue = 0
// Simulate adding each term sequentially
// Simulate computing each term sequentially
for (const term of terms) {
const newValue = currentValue + term
const requiredSkillsForStep = analyzeStepSkills(currentValue, term, newValue)
skills.push(...requiredSkillsForStep)
currentValue = newValue
if (term >= 0) {
// Addition
const newValue = currentValue + term
const requiredSkillsForStep = analyzeStepSkills(currentValue, term, newValue)
skills.push(...requiredSkillsForStep)
currentValue = newValue
} else {
// Subtraction (term is negative, so we subtract its absolute value)
const absTerm = Math.abs(term)
const newValue = currentValue - absTerm
const requiredSkillsForStep = analyzeSubtractionStepSkills(currentValue, absTerm, newValue)
skills.push(...requiredSkillsForStep)
currentValue = newValue
}
}
return [...new Set(skills)] // Remove duplicates
}
/**
* A single step in the generation trace
*/
export interface GenerationTraceStep {
stepNumber: number
operation: string // e.g., "0 + 3 = 3" or "3 + 4 = 7"
accumulatedBefore: number
termAdded: number
accumulatedAfter: number
skillsUsed: string[]
explanation: string
}
/**
* Full generation trace for a problem
*/
export interface GenerationTrace {
terms: number[]
answer: number
steps: GenerationTraceStep[]
allSkills: string[]
}
/**
* Generates a human-readable explanation for a single step
*/
function generateStepExplanation(
before: number,
term: number,
after: number,
skills: string[],
isSubtraction: boolean = false
): string {
if (skills.length === 0) {
return isSubtraction
? `Subtract ${term} directly (no skill needed)`
: `Add ${term} directly (no skill needed)`
}
const explanations: string[] = []
for (const skill of skills) {
// Addition skills
if (skill === 'basic.directAddition') {
explanations.push(`direct addition of ${term}`)
} else if (skill === 'basic.heavenBead') {
explanations.push('use heaven bead (5)')
} else if (skill === 'basic.simpleCombinations') {
explanations.push('simple combination (5+n)')
} else if (skill.startsWith('fiveComplements.')) {
// e.g., "fiveComplements.4=5-1" -> "+4 = +5-1"
const match = skill.match(/fiveComplements\.(\d)=5-(\d)/)
if (match) {
explanations.push(`five complement: +${match[1]} = +5-${match[2]}`)
}
} else if (skill.startsWith('tenComplements.')) {
// e.g., "tenComplements.9=10-1" -> "+9 = +10-1"
const match = skill.match(/tenComplements\.(\d)=10-(\d)/)
if (match) {
explanations.push(`ten complement: +${match[1]} = +10-${match[2]} (carry)`)
}
}
// Subtraction skills
else if (skill === 'basic.directSubtraction') {
explanations.push(`direct subtraction of ${term}`)
} else if (skill === 'basic.heavenBeadSubtraction') {
explanations.push('remove heaven bead (5)')
} else if (skill === 'basic.simpleCombinationsSub') {
explanations.push('simple subtraction combination')
} else if (skill.startsWith('fiveComplementsSub.')) {
// e.g., "fiveComplementsSub.-4=-5+1" -> "-4 = -5+1"
const match = skill.match(/fiveComplementsSub\.-(\d)=-5\+(\d)/)
if (match) {
explanations.push(`five complement: -${match[1]} = -5+${match[2]}`)
}
} else if (skill.startsWith('tenComplementsSub.')) {
// e.g., "tenComplementsSub.-9=+1-10" -> "-9 = +1-10"
const match = skill.match(/tenComplementsSub\.-(\d)=\+(\d)-10/)
if (match) {
explanations.push(`ten complement: -${match[1]} = +${match[2]}-10 (borrow)`)
}
}
}
const beforeOnes = before % 10
const termOnes = term % 10
const op = isSubtraction ? '-' : '+'
const resultOnes = isSubtraction ? (before - term) % 10 : (before + term) % 10
const carryBorrow = isSubtraction
? beforeOnes < termOnes
? ' (borrow)'
: ''
: before + term >= 10
? ' (carry)'
: ''
return `${before} ${op} ${term}: ones column ${beforeOnes}${op}${termOnes}=${resultOnes}${carryBorrow}${explanations.join(', ')}`
}
/**
* Analyzes skills needed for a single addition step: currentValue + term = newValue
*/
@ -362,8 +474,8 @@ export function generateSingleProblem(
// Generate random number of terms (3 to 5 as specified)
const termCount = Math.floor(Math.random() * 3) + 3 // 3-5 terms
// Generate the sequence of numbers to add
const terms = generateSequence(
// Generate the sequence of numbers to add (now returns trace with provenance)
const sequenceResult = generateSequence(
constraints,
termCount,
requiredSkills,
@ -371,16 +483,17 @@ export function generateSingleProblem(
forbiddenSkills
)
if (!terms) continue // Failed to generate valid sequence
if (!sequenceResult) continue // Failed to generate valid sequence
const sum = terms.reduce((acc, term) => acc + term, 0)
const { terms, trace } = sequenceResult
const sum = trace.answer
// Check sum constraints
if (constraints.maxSum && sum > constraints.maxSum) continue
if (constraints.minSum && sum < constraints.minSum) continue
// Analyze what skills this sequential addition requires
const problemSkills = analyzeRequiredSkills(terms, sum)
// Use skills from the trace (provenance from the generator itself)
const problemSkills = trace.allSkills
// Determine difficulty based on skills required
let difficulty: 'easy' | 'medium' | 'hard' = 'easy'
@ -397,6 +510,7 @@ export function generateSingleProblem(
requiredSkills: problemSkills,
difficulty,
explanation: generateSequentialExplanation(terms, sum, problemSkills),
generationTrace: trace, // Include provenance trace
}
// Check if problem matches skill requirements
@ -408,8 +522,29 @@ export function generateSingleProblem(
return null // Failed to generate a suitable problem
}
/** Result from generating a sequence, includes provenance trace */
interface SequenceResult {
terms: number[]
trace: GenerationTrace
}
/**
* Generates a sequence of numbers that can be added using only the specified skills
* Checks if any subtraction skills are enabled in a skill set
*/
function hasSubtractionSkills(skillSet: SkillSet): boolean {
return (
skillSet.basic.directSubtraction ||
skillSet.basic.heavenBeadSubtraction ||
skillSet.basic.simpleCombinationsSub ||
Object.values(skillSet.fiveComplementsSub).some(Boolean) ||
Object.values(skillSet.tenComplementsSub).some(Boolean)
)
}
/**
* Generates a sequence of numbers that can be computed using only the specified skills.
* Supports both addition and subtraction operations.
* Also builds a trace showing what skills were computed at each step.
*/
function generateSequence(
constraints: ProblemConstraints,
@ -417,49 +552,100 @@ function generateSequence(
requiredSkills: SkillSet,
targetSkills?: Partial<SkillSet>,
forbiddenSkills?: Partial<SkillSet>
): number[] | null {
): SequenceResult | null {
const terms: number[] = []
const steps: GenerationTraceStep[] = []
let currentValue = 0
// Check if we can use subtraction
const canSubtract = hasSubtractionSkills(requiredSkills)
for (let i = 0; i < termCount; i++) {
// Try to find a valid next term
const validTerm = findValidNextTerm(
// Try to find a valid next term (returns term + skills it computed)
// For first term, always add (can't subtract from 0)
const allowSubtraction = canSubtract && i > 0 && currentValue > 0
const result = findValidNextTermWithTrace(
currentValue,
constraints,
requiredSkills,
targetSkills,
forbiddenSkills,
i === termCount - 1 // isLastTerm
i === termCount - 1, // isLastTerm
allowSubtraction
)
if (validTerm === null) return null // Couldn't find valid term
if (result === null) return null // Couldn't find valid term
terms.push(validTerm)
currentValue += validTerm
const { term, skillsUsed, isSubtraction } = result
const newValue = isSubtraction ? currentValue - term : currentValue + term
// Build trace step with the skills the generator computed
const explanation = generateStepExplanation(
currentValue,
term,
newValue,
skillsUsed,
isSubtraction
)
const operation = isSubtraction
? `${currentValue} - ${term} = ${newValue}`
: `${currentValue} + ${term} = ${newValue}`
steps.push({
stepNumber: i + 1,
operation,
accumulatedBefore: currentValue,
termAdded: isSubtraction ? -term : term,
accumulatedAfter: newValue,
skillsUsed,
explanation,
})
// Store the signed term for the problem
terms.push(isSubtraction ? -term : term)
currentValue = newValue
}
return terms
return {
terms,
trace: {
terms,
answer: currentValue,
steps,
allSkills: [...new Set(steps.flatMap((s) => s.skillsUsed))],
},
}
}
/** Result from findValidNextTermWithTrace */
interface TermWithSkills {
term: number
skillsUsed: string[]
isSubtraction: boolean
}
/**
* Finds a valid next term in the sequence
* Finds a valid next term in the sequence and returns both the term and
* the skills that were computed for it (provenance).
* Supports both addition and subtraction operations.
*/
function findValidNextTerm(
function findValidNextTermWithTrace(
currentValue: number,
constraints: ProblemConstraints,
requiredSkills: SkillSet,
targetSkills?: Partial<SkillSet>,
forbiddenSkills?: Partial<SkillSet>,
isLastTerm: boolean = false
): number | null {
isLastTerm: boolean = false,
allowSubtraction: boolean = false
): TermWithSkills | null {
const { min, max } = constraints.numberRange
const candidates: number[] = []
const candidates: TermWithSkills[] = []
// Try each possible term value
// Try each possible ADDITION term value
for (let term = min; term <= max; term++) {
const newValue = currentValue + term
// Check if this addition step is valid
// Check if this addition step is valid - THIS is the provenance computation
const stepSkills = analyzeStepSkills(currentValue, term, newValue)
// Check if the step uses only allowed skills (and no forbidden skills)
@ -474,7 +660,35 @@ function findValidNextTerm(
})
if (usesValidSkills) {
candidates.push(term)
candidates.push({ term, skillsUsed: stepSkills, isSubtraction: false })
}
}
// Try each possible SUBTRACTION term value (if allowed)
if (allowSubtraction) {
for (let term = min; term <= max; term++) {
const newValue = currentValue - term
// Skip if result would be negative
if (newValue < 0) continue
// Check if this subtraction step is valid
const stepSkills = analyzeSubtractionStepSkills(currentValue, term, newValue)
// Check if the step uses only allowed skills (and no forbidden skills)
const usesValidSkills = stepSkills.every((skillPath) => {
// Must use only required skills
if (!isSkillEnabled(skillPath, requiredSkills)) return false
// Must not use forbidden skills
if (forbiddenSkills && isSkillEnabled(skillPath, forbiddenSkills)) return false
return true
})
if (usesValidSkills) {
candidates.push({ term, skillsUsed: stepSkills, isSubtraction: true })
}
}
}
@ -482,12 +696,9 @@ function findValidNextTerm(
// If we have target skills and this is not the last term, try to pick a term that uses target skills
if (targetSkills && !isLastTerm) {
const targetCandidates = candidates.filter((term) => {
const newValue = currentValue + term
const stepSkills = analyzeStepSkills(currentValue, term, newValue)
return stepSkills.some((skillPath) => isSkillEnabled(skillPath, targetSkills))
})
const targetCandidates = candidates.filter((candidate) =>
candidate.skillsUsed.some((skillPath) => isSkillEnabled(skillPath, targetSkills))
)
if (targetCandidates.length > 0) {
return targetCandidates[Math.floor(Math.random() * targetCandidates.length)]
@ -499,17 +710,23 @@ function findValidNextTerm(
}
/**
* Generates an explanation for how to solve the sequential addition problem
* Generates an explanation for how to solve the sequential problem (addition and/or subtraction)
*/
function generateSequentialExplanation(terms: number[], sum: number, skills: string[]): string {
const explanations: string[] = []
// Check if problem has mixed operations
const hasSubtraction = terms.some((t) => t < 0)
// Create vertical display format for explanation
const verticalDisplay = `${terms.map((term) => ` ${term}`).join('\n')}\n---\n ${sum}`
const verticalDisplay = `${terms.map((term) => ` ${term >= 0 ? '+' : ''}${term}`).join('\n')}\n---\n ${sum}`
explanations.push(`Calculate this problem by adding each number in sequence:\n${verticalDisplay}`)
const actionWord = hasSubtraction ? 'computing' : 'adding'
explanations.push(
`Calculate this problem by ${actionWord} each number in sequence:\n${verticalDisplay}`
)
// Skill-specific explanations
// Skill-specific explanations - Addition
if (skills.includes('basic.directAddition')) {
explanations.push('Use direct addition for numbers 1-4.')
}
@ -522,17 +739,44 @@ function generateSequentialExplanation(terms: number[], sum: number, skills: str
explanations.push('Use combinations of heaven and earth beads for 6-9.')
}
if (skills.some((skill) => skill.startsWith('fiveComplements'))) {
const complements = skills.filter((skill) => skill.startsWith('fiveComplements'))
if (skills.some((skill) => skill.startsWith('fiveComplements.'))) {
const complements = skills.filter((skill) => skill.startsWith('fiveComplements.'))
explanations.push(
`Apply five complements: ${complements.map((s) => s.split('.')[1]).join(', ')}.`
`Apply five complements (addition): ${complements.map((s) => s.split('.')[1]).join(', ')}.`
)
}
if (skills.some((skill) => skill.startsWith('tenComplements'))) {
const complements = skills.filter((skill) => skill.startsWith('tenComplements'))
if (skills.some((skill) => skill.startsWith('tenComplements.'))) {
const complements = skills.filter((skill) => skill.startsWith('tenComplements.'))
explanations.push(
`Apply ten complements: ${complements.map((s) => s.split('.')[1]).join(', ')}.`
`Apply ten complements (addition): ${complements.map((s) => s.split('.')[1]).join(', ')}.`
)
}
// Skill-specific explanations - Subtraction
if (skills.includes('basic.directSubtraction')) {
explanations.push('Use direct subtraction for numbers 1-4.')
}
if (skills.includes('basic.heavenBeadSubtraction')) {
explanations.push('Remove the heaven bead when subtracting 5.')
}
if (skills.includes('basic.simpleCombinationsSub')) {
explanations.push('Use subtraction combinations for 6-9.')
}
if (skills.some((skill) => skill.startsWith('fiveComplementsSub.'))) {
const complements = skills.filter((skill) => skill.startsWith('fiveComplementsSub.'))
explanations.push(
`Apply five complements (subtraction): ${complements.map((s) => s.split('.')[1]).join(', ')}.`
)
}
if (skills.some((skill) => skill.startsWith('tenComplementsSub.'))) {
const complements = skills.filter((skill) => skill.startsWith('tenComplementsSub.'))
explanations.push(
`Apply ten complements (subtraction/borrowing): ${complements.map((s) => s.split('.')[1]).join(', ')}.`
)
}