feat: operator-specific scaffolding for mixed mastery mode

**Problem:**
In mixed mastery mode, we were merging display rules from both skills
using "least scaffolding wins", which meant advanced subtraction would
remove scaffolding needed for beginner addition problems.

**Solution:**
Store separate display rules for each operator and apply them per-problem.

**Implementation:**

1. **Schema changes (config-schemas.ts)**:
   - Added optional `additionDisplayRules` to MasteryConfig
   - Added optional `subtractionDisplayRules` to MasteryConfig
   - These are populated only for mastery+mixed mode

2. **Validation changes (validation.ts)**:
   - For mastery+mixed: Query both skills and store their recommendedScaffolding
     separately as additionDisplayRules and subtractionDisplayRules
   - For single operator: Continue using displayRules as before

3. **Rendering changes (typstGenerator.ts)**:
   - Check if operator-specific rules exist for mastery mode
   - Addition problems (+): Use additionDisplayRules if available
   - Subtraction problems (-): Use subtractionDisplayRules if available
   - Fallback to displayRules if operator-specific rules not present

**Result:**
- Addition problems get scaffolding from addition skill level
- Subtraction problems get scaffolding from subtraction skill level
- Each problem type has appropriate support for its difficulty

**Example:**
- Single-digit addition (needs carry boxes) + Five-digit subtraction mastery (no scaffolding)
- Addition problems: Show carry boxes whenRegrouping
- Subtraction problems: Never show scaffolding

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-10 14:28:48 -06:00
parent a463d088d7
commit 4d7d000046
3 changed files with 179 additions and 36 deletions

View File

@@ -65,10 +65,21 @@ function generatePageTypst(
p.operator === '+'
? analyzeProblem(p.a, p.b)
: analyzeSubtractionProblem(p.minuend, p.subtrahend)
const displayOptions = resolveDisplayForProblem(
config.displayRules as any, // Cast for backward compatibility with configs missing new fields
meta
)
// Choose display rules based on operator (for mastery+mixed mode)
let rulesForProblem = config.displayRules as any
if (config.mode === 'mastery') {
const masteryConfig = config as any
// If we have operator-specific rules (mastery+mixed), use them
if (p.operator === '+' && masteryConfig.additionDisplayRules) {
rulesForProblem = masteryConfig.additionDisplayRules
} else if (p.operator === '-' && masteryConfig.subtractionDisplayRules) {
rulesForProblem = masteryConfig.subtractionDisplayRules
}
}
const displayOptions = resolveDisplayForProblem(rulesForProblem, meta)
return {
...p,
@@ -91,11 +102,14 @@ function generatePageTypst(
})
// DEBUG: Show first 3 problems' ten-frames status
console.log('[TYPST DEBUG] First 3 enriched problems:', enrichedProblems.slice(0, 3).map((p, i) => ({
index: i,
problem: p.operator === '+' ? `${p.a} + ${p.b}` : `${p.minuend} ${p.subtrahend}`,
showTenFrames: p.showTenFrames,
})))
console.log(
'[TYPST DEBUG] First 3 enriched problems:',
enrichedProblems.slice(0, 3).map((p, i) => ({
index: i,
problem: p.operator === '+' ? `${p.a} + ${p.b}` : `${p.minuend} ${p.subtrahend}`,
showTenFrames: p.showTenFrames,
}))
)
// Generate Typst problem data with per-problem display flags
const problemsTypst = enrichedProblems

View File

@@ -169,7 +169,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
const operator = formState.operator ?? 'addition'
if (operator === 'mixed') {
// Mixed mode: Merge scaffolding from both skills
// Mixed mode: Store SEPARATE display rules for each operator
// The typstGenerator will choose which rules to apply per-problem
const addSkillId = formState.currentAdditionSkillId
const subSkillId = formState.currentSubtractionSkillId
@@ -178,31 +179,8 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
const subSkill = getSkillById(subSkillId as any)
if (addSkill?.recommendedScaffolding && subSkill?.recommendedScaffolding) {
// Use the LEAST scaffolding from both (most restrictive)
// This ensures mastery-level problems have minimal scaffolding
baseDisplayRules = {
// Take 'never' if either skill recommends it
carryBoxes:
addSkill.recommendedScaffolding.carryBoxes === 'never' ||
subSkill.recommendedScaffolding.carryBoxes === 'never'
? 'never'
: 'whenRegrouping',
answerBoxes:
addSkill.recommendedScaffolding.answerBoxes === 'never' ||
subSkill.recommendedScaffolding.answerBoxes === 'never'
? 'never'
: 'always',
placeValueColors:
addSkill.recommendedScaffolding.placeValueColors === 'never' ||
subSkill.recommendedScaffolding.placeValueColors === 'never'
? 'never'
: addSkill.recommendedScaffolding.placeValueColors,
tenFrames: 'never', // Always off for mastery
problemNumbers: 'always',
cellBorders: 'always',
borrowNotation: subSkill.recommendedScaffolding.borrowNotation,
borrowingHints: 'never',
}
// Store both separately - will be used per-problem in typstGenerator
// Note: This will be added to the config below as additionDisplayRules/subtractionDisplayRules
}
}
} else {
@@ -226,7 +204,9 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
...((formState.displayRules as any) ?? {}), // Override with provided rules if any
}
config = {
// Build config with operator-specific display rules for mixed mode
const operator = formState.operator ?? 'addition'
const baseConfig = {
version: 4,
mode: mode as 'smart' | 'mastery', // Preserve the actual mode
displayRules,
@@ -234,6 +214,31 @@ export function validateWorksheetConfig(formState: WorksheetFormState): Validati
currentStepId: formState.currentStepId, // Mastery progression tracking
...sharedFields,
}
// Add operator-specific display rules for mastery+mixed mode
if (mode === 'mastery' && operator === 'mixed') {
const addSkillId = formState.currentAdditionSkillId
const subSkillId = formState.currentSubtractionSkillId
if (addSkillId && subSkillId) {
const addSkill = getSkillById(addSkillId as any)
const subSkill = getSkillById(subSkillId as any)
if (addSkill?.recommendedScaffolding && subSkill?.recommendedScaffolding) {
config = {
...baseConfig,
additionDisplayRules: { ...addSkill.recommendedScaffolding },
subtractionDisplayRules: { ...subSkill.recommendedScaffolding },
} as any
} else {
config = baseConfig as any
}
} else {
config = baseConfig as any
}
} else {
config = baseConfig as any
}
} else {
// Manual mode: Use boolean flags for uniform display
config = {

View File

@@ -400,6 +400,130 @@ const additionConfigV4MasterySchema = additionConfigV4BaseSchema.extend({
]),
}),
// Optional: Separate display rules for mixed mode (operator-specific scaffolding)
// When operator='mixed', additionDisplayRules applies to addition problems,
// subtractionDisplayRules applies to subtraction problems
additionDisplayRules: z
.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowNotation: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowingHints: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
})
.optional(),
subtractionDisplayRules: z
.object({
carryBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
answerBoxes: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
placeValueColors: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
tenFrames: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
problemNumbers: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
cellBorders: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowNotation: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
borrowingHints: z.enum([
'always',
'never',
'whenRegrouping',
'whenMultipleRegroups',
'when3PlusDigits',
]),
})
.optional(),
// Optional: Current step in mastery progression path
currentStepId: z.string().optional(),
})