feat(practice): add complexity budget system and toggleable session parts

- Add skill complexity budget system with base costs per skill type:
  - Basic skills: 0 (trivial bead movements)
  - Five complements: 1 (single mental substitution)
  - Ten complements: 2 (cross-column operations)
  - Cascading operations: 3 (multi-column)

- Add per-term complexity debug overlay in VerticalProblem (toggle via visual debug mode)
  - Shows total cost per term and individual skill costs
  - Highlights over-budget terms in red

- Make session structure parts toggleable in configure page:
  - Can enable/disable abacus, visualization, and linear parts
  - Time estimates, problem counts adjust dynamically
  - At least one part must remain enabled

- Fix max terms per problem not being respected:
  - generateSingleProblem was hardcoding 3-5 terms
  - Now properly uses minTerms/maxTerms from constraints

- Set visualization complexity budget to 3 (more restrictive)
- Hide complexity badges for zero-cost (basic) skills in ManualSkillSelector

🤖 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 20:18:20 -06:00
parent 9159608dcd
commit 5d61de4bf6
20 changed files with 1738 additions and 235 deletions

View File

@ -135,13 +135,19 @@
"Bash(ls:*)",
"Bash(mcp__sqlite__list_tables:*)",
"Bash(mcp__sqlite__read_query:*)",
"Bash(gh api:*)"
"Bash(gh api:*)",
"Bash(xargs basename:*)",
"Bash(apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx )",
"Bash(apps/web/src/components/practice/ManualSkillSelector.tsx )",
"Bash(apps/web/src/components/practice/VerticalProblem.tsx )",
"Bash(apps/web/src/components/practice/VerticalProblem.stories.tsx )",
"Bash(apps/web/src/types/tutorial.ts )",
"Bash(apps/web/src/utils/problemGenerator.ts )",
"Bash(apps/web/src/utils/__tests__/cascadingRegrouping.test.ts)"
],
"deny": [],
"ask": []
},
"enableAllProjectMcpServers": true,
"enabledMcpjsonServers": [
"sqlite"
]
"enabledMcpjsonServers": ["sqlite"]
}

View File

@ -1,8 +1,16 @@
import { AbacusDisplayProvider } from '@soroban/abacus-react'
import type { Preview } from '@storybook/nextjs'
import { NextIntlClientProvider } from 'next-intl'
import React from 'react'
import { ThemeProvider } from '../src/contexts/ThemeContext'
import tutorialEn from '../src/i18n/locales/tutorial/en.json'
import '../styled-system/styles.css'
// Merge messages for Storybook (add more as needed)
const messages = {
tutorial: tutorialEn.tutorial,
}
const preview: Preview = {
parameters: {
controls: {
@ -15,7 +23,11 @@ const preview: Preview = {
decorators: [
(Story) => (
<ThemeProvider>
<Story />
<NextIntlClientProvider locale="en" messages={messages}>
<AbacusDisplayProvider>
<Story />
</AbacusDisplayProvider>
</NextIntlClientProvider>
</ThemeProvider>
),
],

View File

@ -116,13 +116,9 @@
"abacus_settings_user_id_users_id_fk": {
"name": "abacus_settings_user_id_users_id_fk",
"tableFrom": "abacus_settings",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -240,9 +236,7 @@
"indexes": {
"arcade_rooms_code_unique": {
"name": "arcade_rooms_code_unique",
"columns": [
"code"
],
"columns": ["code"],
"isUnique": true
}
},
@ -339,26 +333,18 @@
"arcade_sessions_room_id_arcade_rooms_id_fk": {
"name": "arcade_sessions_room_id_arcade_rooms_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
},
"arcade_sessions_user_id_users_id_fk": {
"name": "arcade_sessions_user_id_users_id_fk",
"tableFrom": "arcade_sessions",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -424,9 +410,7 @@
"indexes": {
"players_user_id_idx": {
"name": "players_user_id_idx",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": false
}
},
@ -434,13 +418,9 @@
"players_user_id_users_id_fk": {
"name": "players_user_id_users_id_fk",
"tableFrom": "players",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -514,9 +494,7 @@
"indexes": {
"idx_room_members_user_id_unique": {
"name": "idx_room_members_user_id_unique",
"columns": [
"user_id"
],
"columns": ["user_id"],
"isUnique": true
}
},
@ -524,13 +502,9 @@
"room_members_room_id_arcade_rooms_id_fk": {
"name": "room_members_room_id_arcade_rooms_id_fk",
"tableFrom": "room_members",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -605,13 +579,9 @@
"room_member_history_room_id_arcade_rooms_id_fk": {
"name": "room_member_history_room_id_arcade_rooms_id_fk",
"tableFrom": "room_member_history",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -713,10 +683,7 @@
"indexes": {
"idx_room_invitations_user_room": {
"name": "idx_room_invitations_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@ -724,13 +691,9 @@
"room_invitations_room_id_arcade_rooms_id_fk": {
"name": "room_invitations_room_id_arcade_rooms_id_fk",
"tableFrom": "room_invitations",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -833,13 +796,9 @@
"room_reports_room_id_arcade_rooms_id_fk": {
"name": "room_reports_room_id_arcade_rooms_id_fk",
"tableFrom": "room_reports",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -918,10 +877,7 @@
"indexes": {
"idx_room_bans_user_room": {
"name": "idx_room_bans_user_room",
"columns": [
"user_id",
"room_id"
],
"columns": ["user_id", "room_id"],
"isUnique": true
}
},
@ -929,13 +885,9 @@
"room_bans_room_id_arcade_rooms_id_fk": {
"name": "room_bans_room_id_arcade_rooms_id_fk",
"tableFrom": "room_bans",
"columnsFrom": [
"room_id"
],
"columnsFrom": ["room_id"],
"tableTo": "arcade_rooms",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -998,13 +950,9 @@
"user_stats_user_id_users_id_fk": {
"name": "user_stats_user_id_users_id_fk",
"tableFrom": "user_stats",
"columnsFrom": [
"user_id"
],
"columnsFrom": ["user_id"],
"tableTo": "users",
"columnsTo": [
"id"
],
"columnsTo": ["id"],
"onUpdate": "no action",
"onDelete": "cascade"
}
@ -1062,16 +1010,12 @@
"indexes": {
"users_guest_id_unique": {
"name": "users_guest_id_unique",
"columns": [
"guest_id"
],
"columns": ["guest_id"],
"isUnique": true
},
"users_email_unique": {
"name": "users_email_unique",
"columns": [
"email"
],
"columns": ["email"],
"isUnique": true
}
},
@ -1091,4 +1035,4 @@
"internal": {
"indexes": {}
}
}
}

View File

@ -206,4 +206,4 @@
"breakpoints": true
}
]
}
}

View File

@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import type { SessionPlan } from '@/db/schema/session-plans'
import {
ActiveSessionExistsError,
type EnabledParts,
type GenerateSessionPlanOptions,
generateSessionPlan,
getActiveSessionPlan,
@ -43,12 +44,16 @@ export async function GET(_request: NextRequest, { params }: RouteParams) {
/**
* POST /api/curriculum/[playerId]/sessions/plans
* Generate a new three-part session plan
* Generate a new session plan
*
* Body:
* - durationMinutes: number (required) - Total session duration
* - abacusTermCount?: { min: number, max: number } - Term count for abacus part
* (visualization auto-calculates as 75% of abacus)
* - enabledParts?: { abacus: boolean, visualization: boolean, linear: boolean } - Which parts to include
* (default: all enabled)
*
* The plan will automatically include all three parts:
* The plan will include the selected parts:
* - Part 1: Abacus (use physical abacus, vertical format)
* - Part 2: Visualization (mental math, vertical format)
* - Part 3: Linear (mental math, sentence format)
@ -58,7 +63,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
try {
const body = await request.json()
const { durationMinutes } = body
const { durationMinutes, abacusTermCount, enabledParts } = body
if (!durationMinutes || typeof durationMinutes !== 'number') {
return NextResponse.json(
@ -67,9 +72,26 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
)
}
// Validate enabledParts if provided
if (enabledParts) {
const validParts = ['abacus', 'visualization', 'linear']
const enabledCount = validParts.filter((p) => enabledParts[p] === true).length
if (enabledCount === 0) {
return NextResponse.json({ error: 'At least one part must be enabled' }, { status: 400 })
}
}
const options: GenerateSessionPlanOptions = {
playerId,
durationMinutes,
// Pass enabled parts
enabledParts: enabledParts as EnabledParts | undefined,
// Pass config overrides if abacusTermCount is specified
...(abacusTermCount && {
config: {
abacusTermCount,
},
}),
}
const plan = await generateSessionPlan(options)

View File

@ -117,41 +117,78 @@ function groupSkillsByCategory(skillIds: string[]): Map<string, string[]> {
}
/**
* Calculate estimated session breakdown based on duration
* Enabled parts configuration
*/
function calculateEstimates(durationMinutes: number, avgSecondsPerProblem: number) {
type EnabledParts = {
abacus: boolean
visualization: boolean
linear: boolean
}
/**
* Calculate estimated session breakdown based on duration and enabled parts
*/
function calculateEstimates(
durationMinutes: number,
avgSecondsPerProblem: number,
enabledParts: EnabledParts
) {
// Filter to only enabled parts and recalculate weights
const enabledPartTypes = (['abacus', 'visualization', 'linear'] as const).filter(
(type) => enabledParts[type]
)
// If no parts enabled, return zeros
if (enabledPartTypes.length === 0) {
return {
totalProblems: 0,
parts: [
{ type: 'abacus' as const, weight: 0, minutes: 0, problems: 0, enabled: false },
{ type: 'visualization' as const, weight: 0, minutes: 0, problems: 0, enabled: false },
{ type: 'linear' as const, weight: 0, minutes: 0, problems: 0, enabled: false },
],
purposes: { focus: 0, reinforce: 0, review: 0, challenge: 0 },
}
}
// Calculate total weight of enabled parts
const totalEnabledWeight = enabledPartTypes.reduce(
(sum, type) => sum + PART_TIME_WEIGHTS[type],
0
)
const totalProblems = Math.max(3, Math.floor((durationMinutes * 60) / avgSecondsPerProblem))
// Calculate problems per part based on weights
const parts = [
{
type: 'abacus' as const,
weight: PART_TIME_WEIGHTS.abacus,
minutes: Math.round(durationMinutes * PART_TIME_WEIGHTS.abacus),
problems: Math.max(2, Math.round(totalProblems * PART_TIME_WEIGHTS.abacus)),
},
{
type: 'visualization' as const,
weight: PART_TIME_WEIGHTS.visualization,
minutes: Math.round(durationMinutes * PART_TIME_WEIGHTS.visualization),
problems: Math.max(1, Math.round(totalProblems * PART_TIME_WEIGHTS.visualization)),
},
{
type: 'linear' as const,
weight: PART_TIME_WEIGHTS.linear,
minutes: Math.round(durationMinutes * PART_TIME_WEIGHTS.linear),
problems: Math.max(1, Math.round(totalProblems * PART_TIME_WEIGHTS.linear)),
},
]
// Calculate problems per part based on normalized weights
const parts = (['abacus', 'visualization', 'linear'] as const).map((type) => {
const enabled = enabledParts[type]
if (!enabled) {
return { type, weight: 0, minutes: 0, problems: 0, enabled: false }
}
const normalizedWeight = PART_TIME_WEIGHTS[type] / totalEnabledWeight
return {
type,
weight: normalizedWeight,
minutes: Math.round(durationMinutes * normalizedWeight),
problems: Math.max(1, Math.round(totalProblems * normalizedWeight)),
enabled: true,
}
})
// Recalculate actual total problems from enabled parts
const actualTotalProblems = parts.reduce((sum, p) => sum + p.problems, 0)
// Calculate purpose breakdown
const focusCount = Math.round(totalProblems * PURPOSE_WEIGHTS.focus)
const reinforceCount = Math.round(totalProblems * PURPOSE_WEIGHTS.reinforce)
const reviewCount = Math.round(totalProblems * PURPOSE_WEIGHTS.review)
const challengeCount = Math.max(0, totalProblems - focusCount - reinforceCount - reviewCount)
const focusCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.focus)
const reinforceCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.reinforce)
const reviewCount = Math.round(actualTotalProblems * PURPOSE_WEIGHTS.review)
const challengeCount = Math.max(
0,
actualTotalProblems - focusCount - reinforceCount - reviewCount
)
return {
totalProblems,
totalProblems: actualTotalProblems,
parts,
purposes: {
focus: focusCount,
@ -187,10 +224,35 @@ export function ConfigureClient({
// Duration state - use existing plan's duration if available
const [durationMinutes, setDurationMinutes] = useState(existingPlan?.targetDurationMinutes ?? 10)
// Calculate live estimates based on current duration selection
// Term count state - max terms per problem for abacus part
const [abacusMaxTerms, setAbacusMaxTerms] = useState(DEFAULT_PLAN_CONFIG.abacusTermCount.max)
// Enabled parts state - which session parts to include
const [enabledParts, setEnabledParts] = useState<EnabledParts>({
abacus: true,
visualization: true,
linear: true,
})
// Toggle a part on/off
const togglePart = useCallback((partType: keyof EnabledParts) => {
setEnabledParts((prev) => {
// Don't allow disabling the last enabled part
const enabledCount = Object.values(prev).filter(Boolean).length
if (enabledCount === 1 && prev[partType]) {
return prev
}
return { ...prev, [partType]: !prev[partType] }
})
}, [])
// Calculate visualization max terms (75% of abacus)
const visualizationMaxTerms = Math.max(2, Math.round(abacusMaxTerms * 0.75))
// Calculate live estimates based on current duration and enabled parts
const estimates = useMemo(
() => calculateEstimates(durationMinutes, avgSecondsPerProblem),
[durationMinutes, avgSecondsPerProblem]
() => calculateEstimates(durationMinutes, avgSecondsPerProblem, enabledParts),
[durationMinutes, avgSecondsPerProblem, enabledParts]
)
const generatePlan = useGenerateSessionPlan()
@ -231,6 +293,8 @@ export function ConfigureClient({
plan = await generatePlan.mutateAsync({
playerId: studentId,
durationMinutes,
abacusTermCount: { min: 3, max: abacusMaxTerms },
enabledParts,
})
} catch (err) {
if (err instanceof ActiveSessionExistsClientError) {
@ -263,6 +327,8 @@ export function ConfigureClient({
}, [
studentId,
durationMinutes,
abacusMaxTerms,
enabledParts,
existingPlan,
generatePlan,
approvePlan,
@ -387,6 +453,71 @@ export function ConfigureClient({
</div>
</div>
{/* Terms per Problem Selector */}
<div
className={css({
padding: '1.25rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
})}
>
<label
className={css({
display: 'block',
fontSize: '0.75rem',
fontWeight: 'bold',
textTransform: 'uppercase',
letterSpacing: '0.05em',
color: isDark ? 'gray.400' : 'gray.500',
marginBottom: '0.75rem',
})}
>
Numbers per problem (max)
</label>
<div className={css({ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' })}>
{[3, 4, 5, 6, 7, 8].map((terms) => (
<button
key={terms}
type="button"
data-setting={`terms-${terms}`}
onClick={() => setAbacusMaxTerms(terms)}
disabled={isStarting}
className={css({
flex: 1,
padding: '0.75rem 0.5rem',
fontSize: '1.125rem',
fontWeight: 'bold',
color: abacusMaxTerms === terms ? 'white' : isDark ? 'gray.300' : 'gray.700',
backgroundColor:
abacusMaxTerms === terms ? 'blue.500' : isDark ? 'gray.700' : 'gray.100',
borderRadius: '10px',
border: '2px solid',
borderColor: abacusMaxTerms === terms ? 'blue.500' : 'transparent',
cursor: isStarting ? 'not-allowed' : 'pointer',
opacity: isStarting ? 0.6 : 1,
transition: 'all 0.15s ease',
_hover: {
backgroundColor:
abacusMaxTerms === terms ? 'blue.600' : isDark ? 'gray.600' : 'gray.200',
},
})}
>
{terms}
</button>
))}
</div>
<div
className={css({
marginTop: '0.5rem',
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
})}
>
🧮 Abacus: up to {abacusMaxTerms} numbers &nbsp;&nbsp; 🧠 Visualize: up to{' '}
{visualizationMaxTerms} numbers (75%)
</div>
</div>
{/* Live Preview - Summary */}
<div
data-section="session-preview"
@ -435,26 +566,42 @@ export function ConfigureClient({
marginBottom: '0.5rem',
})}
>
Session Structure
Session Structure{' '}
<span className={css({ fontWeight: 'normal', textTransform: 'none' })}>
(tap to toggle)
</span>
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '0.5rem' })}>
{PART_INFO.map((partInfo, index) => {
const partEstimate = estimates.parts[index]
const isEnabled = enabledParts[partInfo.type]
const colors = getPartTypeColors(partInfo.type, isDark)
return (
<div
<button
type="button"
key={partInfo.type}
data-element="part-preview"
data-part={index + 1}
data-enabled={isEnabled}
onClick={() => togglePart(partInfo.type)}
disabled={isStarting}
className={css({
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.625rem 0.75rem',
borderRadius: '10px',
backgroundColor: colors.bg,
border: '1px solid',
borderColor: colors.border,
backgroundColor: isEnabled ? colors.bg : isDark ? 'gray.800' : 'gray.100',
border: '2px solid',
borderColor: isEnabled ? colors.border : isDark ? 'gray.700' : 'gray.300',
opacity: isEnabled ? 1 : 0.5,
cursor: isStarting ? 'not-allowed' : 'pointer',
transition: 'all 0.15s ease',
textAlign: 'left',
width: '100%',
_hover: {
borderColor: isEnabled ? colors.text : isDark ? 'gray.500' : 'gray.400',
},
})}
>
<span className={css({ fontSize: '1.25rem' })}>{partInfo.emoji}</span>
@ -463,25 +610,45 @@ export function ConfigureClient({
className={css({
fontWeight: 'bold',
fontSize: '0.875rem',
color: colors.text,
color: isEnabled ? colors.text : isDark ? 'gray.500' : 'gray.400',
})}
>
Part {index + 1}: {partInfo.label}
{partInfo.label}
</div>
<div
className={css({
fontSize: '0.6875rem',
color: isEnabled
? isDark
? 'gray.400'
: 'gray.500'
: isDark
? 'gray.600'
: 'gray.400',
})}
>
{partInfo.description}
</div>
</div>
<div
className={css({
textAlign: 'right',
fontSize: '0.75rem',
color: colors.text,
color: isEnabled ? colors.text : isDark ? 'gray.600' : 'gray.400',
})}
>
<div className={css({ fontWeight: 'bold' })}>
{partEstimate.problems} problems
</div>
<div>~{partEstimate.minutes} min</div>
{isEnabled ? (
<>
<div className={css({ fontWeight: 'bold' })}>
{partEstimate.problems} problems
</div>
<div>~{partEstimate.minutes} min</div>
</>
) : (
<div className={css({ fontStyle: 'italic' })}>skipped</div>
)}
</div>
</div>
</button>
)
})}
</div>

View File

@ -863,6 +863,7 @@ export function ActiveSession({
isCompleted={true}
correctAnswer={outgoingAttempt.problem.answer}
size="large"
generationTrace={outgoingAttempt.problem.generationTrace}
/>
{/* Feedback stays with outgoing problem */}
<div
@ -926,6 +927,8 @@ export function ActiveSession({
/>
) : undefined
}
generationTrace={attempt.problem.generationTrace}
complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm}
/>
) : (
<LinearProblem

View File

@ -161,3 +161,40 @@ export const AllSkillsMastered: Story = {
export const NewStudent: Story = {
render: () => <InteractiveDemo studentName="New Learner" currentMasteredSkills={[]} />,
}
/**
* This story highlights the complexity badges feature.
* Each skill has a badge indicating its base complexity:
* - 1 (green): Simple single-concept operations
* - 2 (orange): Cross-column operations (ten complements)
* - 3 (red): Cascading multi-column operations
*
* The badges help teachers understand the inherent difficulty of each skill.
*/
export const ComplexityBadgesHighlight: Story = {
render: () => (
<InteractiveDemo
studentName="Demo Student"
currentMasteredSkills={[
// Show variety of complexity levels
'basic.directAddition', // 1★
'tenComplements.9=10-1', // 2★
'advanced.cascadingCarry', // 3★
]}
/>
),
parameters: {
docs: {
description: {
story: `
Demonstrates the complexity badges that appear next to each skill:
- **1 Simple** (green): Basic operations like direct addition, heaven bead, and five complements
- **2 Cross-column** (orange): Ten complement operations that involve carrying/borrowing
- **3 Cascading** (red): Advanced operations like cascading carry (999+1=1000)
The complexity legend at the top explains what each badge means.
`,
},
},
},
}

View File

@ -4,6 +4,7 @@ import * as Accordion from '@radix-ui/react-accordion'
import * as Dialog from '@radix-ui/react-dialog'
import { useEffect, useState } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { BASE_SKILL_COMPLEXITY } from '@/utils/skillComplexity'
import { css } from '../../../styled-system/css'
/**
@ -78,6 +79,136 @@ const SKILL_CATEGORIES = {
type CategoryKey = keyof typeof SKILL_CATEGORIES
/**
* ComplexityBadge - Shows the base complexity cost for a skill
*
* Base costs represent intrinsic mechanical complexity:
* - 0 Trivial: Basic bead movements, no mental calculation
* - 1 Simple: Single complement (one mental substitution)
* - 2 Cross-column: Operations that cross column boundaries (ten complements)
* - 3 Cascading: Multi-column cascading operations (advanced)
*/
function ComplexityBadge({ skillId, isDark }: { skillId: string; isDark: boolean }) {
const baseCost = BASE_SKILL_COMPLEXITY[skillId] ?? 1
// No badge for zero-cost (trivial) skills
if (baseCost === 0) {
return null
}
const styles: Record<number, { bg: string; text: string; label: string }> = {
1: {
bg: isDark ? 'green.900' : 'green.100',
text: isDark ? 'green.300' : 'green.700',
label: '1★',
},
2: {
bg: isDark ? 'orange.900' : 'orange.100',
text: isDark ? 'orange.300' : 'orange.700',
label: '2★',
},
3: {
bg: isDark ? 'red.900' : 'red.100',
text: isDark ? 'red.300' : 'red.700',
label: '3★',
},
}
const style = styles[baseCost] ?? styles[1]
return (
<span
data-element="complexity-badge"
data-complexity={baseCost}
title={`Base complexity: ${baseCost}`}
className={css({
fontSize: '10px',
fontWeight: 'bold',
px: '1.5',
py: '0.5',
borderRadius: 'sm',
bg: style.bg,
color: style.text,
whiteSpace: 'nowrap',
})}
>
{style.label}
</span>
)
}
/**
* ComplexityLegend - Shows explanation of complexity badges
*/
function ComplexityLegend({ isDark }: { isDark: boolean }) {
return (
<div
data-element="complexity-legend"
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '3',
fontSize: 'xs',
color: isDark ? 'gray.400' : 'gray.600',
p: '2',
bg: isDark ? 'gray.750' : 'gray.50',
borderRadius: 'md',
mb: '3',
})}
>
<span className={css({ fontWeight: 'medium' })}>Complexity:</span>
<span className={css({ display: 'flex', alignItems: 'center', gap: '1' })}>
<span
className={css({
fontSize: '10px',
fontWeight: 'bold',
px: '1.5',
py: '0.5',
borderRadius: 'sm',
bg: isDark ? 'green.900' : 'green.100',
color: isDark ? 'green.300' : 'green.700',
})}
>
1
</span>
Simple
</span>
<span className={css({ display: 'flex', alignItems: 'center', gap: '1' })}>
<span
className={css({
fontSize: '10px',
fontWeight: 'bold',
px: '1.5',
py: '0.5',
borderRadius: 'sm',
bg: isDark ? 'orange.900' : 'orange.100',
color: isDark ? 'orange.300' : 'orange.700',
})}
>
2
</span>
Cross-column
</span>
<span className={css({ display: 'flex', alignItems: 'center', gap: '1' })}>
<span
className={css({
fontSize: '10px',
fontWeight: 'bold',
px: '1.5',
py: '0.5',
borderRadius: 'sm',
bg: isDark ? 'red.900' : 'red.100',
color: isDark ? 'red.300' : 'red.700',
})}
>
3
</span>
Cascading
</span>
</div>
)
}
/**
* Book preset mappings (SAI Abacus Mind Math levels)
*/
@ -403,6 +534,9 @@ export function ManualSkillSelector({
</button>
</div>
{/* Complexity Legend */}
<ComplexityLegend isDark={isDark} />
{/* Skills Accordion */}
<Accordion.Root
type="multiple"
@ -547,6 +681,7 @@ export function ManualSkillSelector({
cursor: 'pointer',
})}
/>
<ComplexityBadge skillId={skillId} isDark={isDark} />
<span
className={css({
fontSize: 'sm',
@ -558,6 +693,7 @@ export function ManualSkillSelector({
? 'gray.300'
: 'gray.700',
fontWeight: isSelected ? 'medium' : 'normal',
flex: 1,
})}
>
{skillName}

View File

@ -39,6 +39,10 @@ export function ProblemDebugPanel({
const [copied, setCopied] = useState(false)
const [isCollapsed, setIsCollapsed] = useState(false)
// Get trace data directly from the problem generator
const trace = problem.generationTrace
const budgetConstraint = trace?.budgetConstraint ?? slot.constraints?.maxComplexityBudgetPerTerm
const debugData = {
problem: {
terms: problem.terms,
@ -62,6 +66,16 @@ export function ProblemDebugPanel({
userInput,
phaseName,
},
complexity: {
budgetConstraint: budgetConstraint ?? 'none',
termAnalysis:
trace?.steps.map((step) => ({
term: step.termAdded,
skills: step.skillsUsed,
cost: step.complexityCost,
})) ?? [],
hasTrace: !!trace,
},
}
const debugJson = JSON.stringify(debugData, null, 2)
@ -189,6 +203,91 @@ export function ProblemDebugPanel({
</div>
</div>
{/* Complexity Budget Info */}
<div
data-element="complexity-breakdown"
className={css({
marginBottom: '12px',
padding: '8px',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
borderRadius: '4px',
border: '1px solid rgba(139, 92, 246, 0.3)',
})}
>
<div
className={css({
color: '#a78bfa',
fontWeight: 'bold',
marginBottom: '6px',
display: 'flex',
justifyContent: 'space-between',
})}
>
<span>Complexity Budget</span>
{budgetConstraint !== undefined ? (
<span className={css({ color: '#fbbf24' })}>max {budgetConstraint}/term</span>
) : (
<span className={css({ color: '#6b7280' })}>no limit</span>
)}
</div>
{trace ? (
<div className={css({ display: 'flex', flexDirection: 'column', gap: '4px' })}>
{trace.steps.map((step, i) => {
const cost = step.complexityCost
const isOverBudget =
budgetConstraint !== undefined && cost !== undefined && cost > budgetConstraint
return (
<div
key={i}
className={css({
display: 'flex',
alignItems: 'center',
gap: '6px',
fontSize: '10px',
})}
>
<span
className={css({
color: '#d1d5db',
minWidth: '45px',
})}
>
{step.termAdded >= 0
? `+ ${step.termAdded}`
: `- ${Math.abs(step.termAdded)}`}
</span>
<span
className={css({
color: isOverBudget ? '#f87171' : '#4ade80',
fontWeight: 'bold',
minWidth: '35px',
})}
>
{cost !== undefined ? `${cost}` : '—'}
</span>
<span
className={css({
color: '#9ca3af',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
})}
title={step.skillsUsed.join(', ')}
>
{step.skillsUsed.length > 0 ? step.skillsUsed.join(', ') : '(none)'}
</span>
</div>
)
})}
</div>
) : (
<div className={css({ color: '#f87171', fontSize: '10px' })}>
No generation trace available
</div>
)}
</div>
{/* Full JSON */}
<pre
className={css({

View File

@ -2,6 +2,9 @@
import type { ReactNode } from 'react'
import { useTheme } from '@/contexts/ThemeContext'
import { useVisualDebugSafe } from '@/contexts/VisualDebugContext'
import type { GenerationTrace } from '@/db/schema/session-plans'
import { getBaseComplexity } from '@/utils/skillComplexity'
import { css } from '../../../styled-system/css'
interface VerticalProblemProps {
@ -25,6 +28,10 @@ interface VerticalProblemProps {
rejectedDigit?: string | null
/** Help overlay to render in place of answer boxes when in help mode */
helpOverlay?: ReactNode
/** Generation trace with per-term skills and complexity (for debug overlay) */
generationTrace?: GenerationTrace
/** Complexity budget constraint (for debug overlay) */
complexityBudget?: number
}
/**
@ -47,9 +54,15 @@ export function VerticalProblem({
needHelpTermIndex,
rejectedDigit = null,
helpOverlay,
generationTrace,
complexityBudget,
}: VerticalProblemProps) {
const { resolvedTheme } = useTheme()
const isDark = resolvedTheme === 'dark'
const { isVisualDebugEnabled } = useVisualDebugSafe()
// Get the trace step for a given term index (for debug overlay)
const getTraceStep = (index: number) => generationTrace?.steps[index]
// Calculate all possible prefix sums (intermediate values when entering answer step-by-step)
const prefixSums = terms.reduce((acc, term, i) => {
@ -242,6 +255,73 @@ export function VerticalProblem({
{digit}
</div>
))}
{/* Debug overlay: show skills and complexity for this term */}
{isVisualDebugEnabled &&
(() => {
const traceStep = getTraceStep(index)
if (!traceStep) return null
const cost = traceStep.complexityCost
const isOverBudget =
complexityBudget !== undefined && cost !== undefined && cost > complexityBudget
const skills = traceStep.skillsUsed
// Calculate base cost sum for comparison (without mastery multipliers)
const baseCostSum = skills.reduce((sum, s) => sum + getBaseComplexity(s), 0)
return (
<div
data-element="term-debug-overlay"
className={css({
position: 'absolute',
left: '100%',
marginLeft: '0.5rem',
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.25rem 0.5rem',
backgroundColor: 'rgba(0, 0, 0, 0.85)',
borderRadius: '4px',
fontSize: '0.625rem',
fontFamily: 'monospace',
fontWeight: 'normal',
whiteSpace: 'nowrap',
zIndex: 100,
})}
>
{/* Total complexity cost badge */}
<span
className={css({
padding: '0.125rem 0.375rem',
borderRadius: '4px',
fontWeight: 'bold',
backgroundColor: isOverBudget
? 'rgba(248, 113, 113, 0.3)'
: 'rgba(74, 222, 128, 0.3)',
color: isOverBudget ? '#f87171' : '#4ade80',
border: '1px solid',
borderColor: isOverBudget ? '#f87171' : '#4ade80',
})}
>
{cost !== undefined ? cost : baseCostSum}
</span>
{/* Skills list with individual base costs */}
<span className={css({ color: '#9ca3af' })}>
{skills.length > 0
? skills
.map((s) => {
const baseCost = getBaseComplexity(s)
const shortName = s.split('.').pop()
return `${shortName}(${baseCost})`
})
.join(', ')
: '(none)'}
</span>
</div>
)
})()}
</div>
)
})}

View File

@ -54,6 +54,43 @@ export interface ProblemConstraints {
digitRange?: { min: number; max: number }
termCount?: { min: number; max: number }
operator?: 'addition' | 'subtraction' | 'mixed'
/**
* Maximum complexity budget per term.
*
* Each term's skills are costed using the SkillCostCalculator,
* which factors in both base skill complexity and student mastery.
*
* If set, terms with total cost > budget are rejected during generation.
*/
maxComplexityBudgetPerTerm?: number
}
/**
* 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
/** Complexity cost for this term (if budget system was used) */
complexityCost?: number
}
/**
* Full generation trace for a problem
*/
export interface GenerationTrace {
terms: number[]
answer: number
steps: GenerationTraceStep[]
allSkills: string[]
/** Budget constraint used during generation (if any) */
budgetConstraint?: number
}
export interface GeneratedProblem {
@ -63,6 +100,8 @@ export interface GeneratedProblem {
answer: number
/** Skills this problem exercises */
skillsRequired: string[]
/** Generation trace with per-step skills and costs */
generationTrace?: GenerationTrace
}
/**
@ -399,6 +438,15 @@ export const DEFAULT_PLAN_CONFIG = {
linear: 0.2,
},
/** Term count range for abacus part (how many numbers per problem) */
abacusTermCount: { min: 3, max: 6 },
/** Term count range for visualization part (defaults to 75% of abacus) */
visualizationTermCount: null as { min: number; max: number } | null,
/** Term count range for linear part (same as abacus by default) */
linearTermCount: null as { min: number; max: number } | null,
/** Default seconds per problem if no history */
defaultSecondsPerProblem: 45,
@ -410,6 +458,17 @@ export const DEFAULT_PLAN_CONFIG = {
/** Session timeout in hours - sessions older than this are auto-abandoned on next access */
sessionTimeoutHours: 24,
} as const
/**
* Complexity budget per term for each part type.
* null = no limit (unlimited)
*
* These are evaluated against student-aware costs:
* termCost = Σ(baseCost × masteryMultiplier)
*/
abacusComplexityBudget: null as number | null,
visualizationComplexityBudget: 3 as number | null,
linearComplexityBudget: null as number | null,
}
export type PlanGenerationConfig = typeof DEFAULT_PLAN_CONFIG

View File

@ -30,17 +30,32 @@ export class ActiveSessionExistsClientError extends Error {
}
}
/**
* Which session parts to include
*/
interface EnabledParts {
abacus: boolean
visualization: boolean
linear: boolean
}
async function generateSessionPlan({
playerId,
durationMinutes,
abacusTermCount,
enabledParts,
}: {
playerId: string
durationMinutes: number
/** Max terms per problem for abacus part (visualization auto-calculates as 75%) */
abacusTermCount?: { min: number; max: number }
/** Which parts to include (default: all enabled) */
enabledParts?: EnabledParts
}): Promise<SessionPlan> {
const res = await api(`curriculum/${playerId}/sessions/plans`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ durationMinutes }),
body: JSON.stringify({ durationMinutes, abacusTermCount, enabledParts }),
})
if (!res.ok) {
const errorData = await res.json().catch(() => ({}))

View File

@ -58,6 +58,7 @@ export {
abandonSessionPlan,
approveSessionPlan,
completeSessionPlanEarly,
type EnabledParts,
type GenerateSessionPlanOptions,
generateSessionPlan,
getActiveSessionPlan,

View File

@ -50,6 +50,7 @@ export function generateProblemFromConstraints(constraints: ProblemConstraints):
const generatorConstraints: GeneratorConstraints = {
numberRange: { min: 1, max: maxValue },
minTerms: constraints.termCount?.min || 3,
maxTerms: constraints.termCount?.max || 5,
problemCount: 1,
}
@ -66,6 +67,7 @@ export function generateProblemFromConstraints(constraints: ProblemConstraints):
terms: generatedProblem.terms,
answer: generatedProblem.answer,
skillsRequired: generatedProblem.requiredSkills,
generationTrace: generatedProblem.generationTrace,
}
}

View File

@ -44,10 +44,21 @@ import { getAllSkillMastery, getPlayerCurriculum, getRecentSessions } from './pr
// Plan Generation
// ============================================================================
/**
* Which session parts to include in the generated plan
*/
export interface EnabledParts {
abacus: boolean
visualization: boolean
linear: boolean
}
export interface GenerateSessionPlanOptions {
playerId: string
durationMinutes: number
config?: Partial<PlanGenerationConfig>
/** Which parts to include (default: all enabled) */
enabledParts?: EnabledParts
}
/**
@ -72,10 +83,17 @@ export class ActiveSessionExistsError extends Error {
export async function generateSessionPlan(
options: GenerateSessionPlanOptions
): Promise<SessionPlan> {
const { playerId, durationMinutes, config: configOverrides } = options
const { playerId, durationMinutes, config: configOverrides, enabledParts } = options
const config = { ...DEFAULT_PLAN_CONFIG, ...configOverrides }
// Default: all parts enabled
const partsToInclude: EnabledParts = enabledParts ?? {
abacus: true,
visualization: true,
linear: true,
}
// Check for existing active session (one active session per kid rule)
const existingActive = await getActiveSessionPlan(playerId)
if (existingActive) {
@ -112,42 +130,38 @@ export async function generateSessionPlan(
const struggling = findStrugglingSkills(skillMastery)
const needsReview = findSkillsNeedingReview(skillMastery, config.reviewIntervalDays)
// 4. Build three parts using STUDENT'S MASTERED SKILLS
const parts: SessionPart[] = [
buildSessionPart(
1,
'abacus',
durationMinutes,
avgTimeSeconds,
config,
masteredSkillConstraints,
struggling,
needsReview,
currentPhase
),
buildSessionPart(
2,
'visualization',
durationMinutes,
avgTimeSeconds,
config,
masteredSkillConstraints,
struggling,
needsReview,
currentPhase
),
buildSessionPart(
3,
'linear',
durationMinutes,
avgTimeSeconds,
config,
masteredSkillConstraints,
struggling,
needsReview,
currentPhase
),
]
// 4. Build parts using STUDENT'S MASTERED SKILLS (only enabled parts)
// Normalize part time weights based on which parts are enabled
const enabledPartTypes = (['abacus', 'visualization', 'linear'] as const).filter(
(type) => partsToInclude[type]
)
const totalEnabledWeight = enabledPartTypes.reduce(
(sum, type) => sum + config.partTimeWeights[type],
0
)
// Build only enabled parts with normalized time weights
const parts: SessionPart[] = []
let partNumber = 1 as 1 | 2 | 3
for (const partType of enabledPartTypes) {
const normalizedWeight = config.partTimeWeights[partType] / totalEnabledWeight
parts.push(
buildSessionPart(
partNumber,
partType,
durationMinutes,
avgTimeSeconds,
{ ...config, partTimeWeights: { ...config.partTimeWeights, [partType]: normalizedWeight } },
masteredSkillConstraints,
struggling,
needsReview,
currentPhase,
normalizedWeight
)
)
partNumber = (partNumber + 1) as 1 | 2 | 3
}
// 5. Build summary
const summary = buildSummary(parts, currentPhase, durationMinutes)
@ -190,10 +204,11 @@ function buildSessionPart(
phaseConstraints: ReturnType<typeof getPhaseSkillConstraints>,
struggling: PlayerSkillMastery[],
needsReview: PlayerSkillMastery[],
currentPhase: CurriculumPhase | undefined
currentPhase: CurriculumPhase | undefined,
normalizedWeight?: number
): SessionPart {
// Get time allocation for this part
const partWeight = config.partTimeWeights[type]
// Get time allocation for this part (use normalized weight if provided)
const partWeight = normalizedWeight ?? config.partTimeWeights[type]
const partDurationMinutes = totalDurationMinutes * partWeight
const partProblemCount = Math.max(2, Math.floor((partDurationMinutes * 60) / avgTimeSeconds))
@ -208,7 +223,7 @@ function buildSessionPart(
// Focus slots: current phase, primary skill
for (let i = 0; i < focusCount; i++) {
slots.push(createSlot(slots.length, 'focus', phaseConstraints, type))
slots.push(createSlot(slots.length, 'focus', phaseConstraints, type, config))
}
// Reinforce slots: struggling skills get extra practice
@ -219,7 +234,8 @@ function buildSessionPart(
slots.length,
'reinforce',
skill ? buildConstraintsForSkill(skill) : phaseConstraints,
type
type,
config
)
)
}
@ -232,14 +248,15 @@ function buildSessionPart(
slots.length,
'review',
skill ? buildConstraintsForSkill(skill) : phaseConstraints,
type
type,
config
)
)
}
// Challenge slots: use same mastered skills constraints (all problems should use student's skills)
for (let i = 0; i < challengeCount; i++) {
slots.push(createSlot(slots.length, 'challenge', phaseConstraints, type))
slots.push(createSlot(slots.length, 'challenge', phaseConstraints, type, config))
}
// Shuffle to interleave purposes
@ -494,33 +511,76 @@ export async function abandonSessionPlan(planId: string): Promise<SessionPlan> {
// ============================================================================
/**
* Get term count constraints based on part type
* Get term count constraints based on part type and config
*
* - abacus: Full term count (3-6 terms)
* - visualization: 75% of abacus (easier since no physical abacus) - rounds to 2-4 terms
* - linear: Same as abacus (full difficulty)
* - abacus: Uses config.abacusTermCount
* - visualization: Uses config.visualizationTermCount, or 75% of abacus if null
* - linear: Uses config.linearTermCount, or same as abacus if null
*/
function getTermCountForPartType(partType: SessionPartType): { min: number; max: number } {
if (partType === 'visualization') {
// 75% of abacus term count (3*0.75=2.25→2, 6*0.75=4.5→4)
return { min: 2, max: 4 }
function getTermCountForPartType(
partType: SessionPartType,
config: PlanGenerationConfig
): { min: number; max: number } {
const abacusTerms = config.abacusTermCount
if (partType === 'abacus') {
return abacusTerms
}
// abacus and linear use full term count
return { min: 3, max: 6 }
if (partType === 'visualization') {
// Use explicit config if set, otherwise 75% of abacus
if (config.visualizationTermCount) {
return config.visualizationTermCount
}
return {
min: Math.max(2, Math.round(abacusTerms.min * 0.75)),
max: Math.max(2, Math.round(abacusTerms.max * 0.75)),
}
}
// linear: use explicit config if set, otherwise same as abacus
if (config.linearTermCount) {
return config.linearTermCount
}
return abacusTerms
}
/**
* Get the complexity budget for a part type from config
* Returns undefined if no budget limit (unlimited)
*/
function getComplexityBudgetForPartType(
partType: SessionPartType,
config: PlanGenerationConfig
): number | undefined {
if (partType === 'abacus') {
return config.abacusComplexityBudget ?? undefined
}
if (partType === 'visualization') {
return config.visualizationComplexityBudget ?? undefined
}
// linear
return config.linearComplexityBudget ?? undefined
}
function createSlot(
index: number,
purpose: ProblemSlot['purpose'],
baseConstraints: ReturnType<typeof getPhaseSkillConstraints>,
partType: SessionPartType
partType: SessionPartType,
config: PlanGenerationConfig
): ProblemSlot {
// Get complexity budget for this part type
const maxComplexityBudgetPerTerm = getComplexityBudgetForPartType(partType, config)
const constraints = {
requiredSkills: baseConstraints.requiredSkills,
targetSkills: baseConstraints.targetSkills,
forbiddenSkills: baseConstraints.forbiddenSkills,
termCount: getTermCountForPartType(partType),
termCount: getTermCountForPartType(partType, config),
digitRange: { min: 1, max: 2 },
// Add complexity budget constraint for visualization mode
...(maxComplexityBudgetPerTerm !== undefined && { maxComplexityBudgetPerTerm }),
}
// Pre-generate the problem so it's persisted with the plan

View File

@ -0,0 +1,306 @@
import { describe, it, expect } from 'vitest'
import { analyzeStepSkills, generateSingleProblem } from '../problemGenerator'
import { createSkillCostCalculator, type StudentSkillHistory } from '../skillComplexity'
import type { SkillSet } from '../../types/tutorial'
/**
* Creates a SkillSet with all skills enabled for testing
*/
function createFullSkillSet(): SkillSet {
return {
basic: {
directAddition: true,
heavenBead: true,
simpleCombinations: true,
directSubtraction: true,
heavenBeadSubtraction: true,
simpleCombinationsSub: true,
},
fiveComplements: {
'4=5-1': true,
'3=5-2': true,
'2=5-3': true,
'1=5-4': true,
},
fiveComplementsSub: {
'-4=-5+1': true,
'-3=-5+2': true,
'-2=-5+3': true,
'-1=-5+4': true,
},
tenComplements: {
'9=10-1': true,
'8=10-2': true,
'7=10-3': true,
'6=10-4': true,
'5=10-5': true,
'4=10-6': true,
'3=10-7': true,
'2=10-8': true,
'1=10-9': true,
},
tenComplementsSub: {
'-9=+1-10': true,
'-8=+2-10': true,
'-7=+3-10': true,
'-6=+4-10': true,
'-5=+5-10': true,
'-4=+6-10': true,
'-3=+7-10': true,
'-2=+8-10': true,
'-1=+9-10': true,
},
advanced: {
cascadingCarry: true,
cascadingBorrow: true,
},
}
}
/**
* Tests for the complexity budget filtering feature in the problem generator.
*
* The budget system works as follows:
* - Each term in a problem has a "cost" based on the skills it requires
* - Cost = sum of (baseCost × masteryMultiplier) for each skill
* - If maxComplexityBudgetPerTerm is set, terms exceeding the budget are rejected
*/
describe('Problem Generator Budget Integration', () => {
describe('analyzeStepSkills produces correct skills for budget calculation', () => {
it('should identify basic.directAddition for simple additions', () => {
// 0 + 3 = 3 (direct addition)
const skills = analyzeStepSkills(0, 3, 3)
expect(skills).toContain('basic.directAddition')
})
it('should identify heaven bead usage for adding 5', () => {
// 0 + 5 = 5 (heaven bead)
const skills = analyzeStepSkills(0, 5, 5)
expect(skills).toContain('basic.heavenBead')
})
it('should identify ten complement for carry-producing additions', () => {
// 5 + 9 = 14 (ten complement: +9 = +10 - 1)
const skills = analyzeStepSkills(5, 9, 14)
expect(skills).toContain('tenComplements.9=10-1')
})
})
describe('term costs vary by student mastery', () => {
it('should have low cost for effortless skills', () => {
const history: StudentSkillHistory = {
skills: {
'basic.directAddition': { skillId: 'basic.directAddition', masteryLevel: 'effortless' },
},
}
const calculator = createSkillCostCalculator(history)
// 0 + 3 = 3 (direct addition)
const skills = analyzeStepSkills(0, 3, 3)
const cost = calculator.calculateTermCost(skills)
expect(cost).toBe(1) // base 1 × effortless 1 = 1
})
it('should have high cost for learning skills', () => {
const history: StudentSkillHistory = {
skills: {
'basic.directAddition': { skillId: 'basic.directAddition', masteryLevel: 'learning' },
},
}
const calculator = createSkillCostCalculator(history)
// Same operation: 0 + 3 = 3
const skills = analyzeStepSkills(0, 3, 3)
const cost = calculator.calculateTermCost(skills)
expect(cost).toBe(4) // base 1 × learning 4 = 4
})
it('should have higher cost for ten complement than basic skills', () => {
const history: StudentSkillHistory = {
skills: {
'tenComplements.9=10-1': { skillId: 'tenComplements.9=10-1', masteryLevel: 'effortless' },
},
}
const calculator = createSkillCostCalculator(history)
// 5 + 9 = 14 (ten complement)
const skills = analyzeStepSkills(5, 9, 14)
const cost = calculator.calculateTermCost(skills)
// Ten complement: base 2 × effortless 1 = 2
expect(cost).toBeGreaterThanOrEqual(2)
})
})
describe('budget filtering scenarios', () => {
const fullSkillSet = createFullSkillSet()
it('beginner: same skill costs more than expert', () => {
// Beginner: all skills at learning (unknown = learning)
const beginnerHistory: StudentSkillHistory = { skills: {} }
const beginnerCalc = createSkillCostCalculator(beginnerHistory)
// Expert: ten complement effortless
const expertHistory: StudentSkillHistory = {
skills: {
'tenComplements.9=10-1': { skillId: 'tenComplements.9=10-1', masteryLevel: 'effortless' },
},
}
const expertCalc = createSkillCostCalculator(expertHistory)
// 5 + 9 = 14 (needs ten complement)
const skills = analyzeStepSkills(5, 9, 14)
const beginnerCost = beginnerCalc.calculateTermCost(skills)
const expertCost = expertCalc.calculateTermCost(skills)
expect(beginnerCost).toBeGreaterThan(expertCost)
expect(beginnerCost).toBe(8) // base 2 × learning 4 = 8
expect(expertCost).toBe(2) // base 2 × effortless 1 = 2
})
it('expert can fit complex terms in tight budget', () => {
const expertHistory: StudentSkillHistory = {
skills: {
'tenComplements.9=10-1': { skillId: 'tenComplements.9=10-1', masteryLevel: 'effortless' },
'basic.heavenBead': { skillId: 'basic.heavenBead', masteryLevel: 'effortless' },
},
}
const calculator = createSkillCostCalculator(expertHistory)
const skills = analyzeStepSkills(5, 9, 14)
const cost = calculator.calculateTermCost(skills)
// With budget 3, expert can fit this
expect(cost <= 3).toBe(true)
})
it('beginner cannot fit same term in tight budget', () => {
const beginnerHistory: StudentSkillHistory = { skills: {} }
const calculator = createSkillCostCalculator(beginnerHistory)
const skills = analyzeStepSkills(5, 9, 14)
const cost = calculator.calculateTermCost(skills)
// With budget 3, beginner cannot fit this
expect(cost > 3).toBe(true)
})
})
describe('generateSingleProblem with budget', () => {
const fullSkillSet = createFullSkillSet()
it('should respect budget constraint when generating problems', () => {
// Create a calculator where ten complements are expensive (learning)
const beginnerHistory: StudentSkillHistory = {
skills: {
'basic.directAddition': { skillId: 'basic.directAddition', masteryLevel: 'effortless' },
'basic.heavenBead': { skillId: 'basic.heavenBead', masteryLevel: 'effortless' },
'basic.simpleCombinations': {
skillId: 'basic.simpleCombinations',
masteryLevel: 'effortless',
},
},
}
const calculator = createSkillCostCalculator(beginnerHistory)
// Generate a problem with tight budget (2)
// This should only allow simple operations
const problem = generateSingleProblem({
constraints: {
numberRange: { min: 1, max: 9 },
maxTerms: 5,
problemCount: 1,
maxComplexityBudgetPerTerm: 2,
},
requiredSkills: fullSkillSet,
costCalculator: calculator,
attempts: 200,
})
// With a tight budget, the problem should avoid expensive skills
// or return null if impossible
if (problem) {
// Verify no ten complements (which would cost 8 for a beginner)
const hasTenComplement = problem.requiredSkills.some((s) => s.includes('tenComplements'))
expect(hasTenComplement).toBe(false)
}
})
it('should allow more complex operations with higher budget', () => {
// Expert history - all skills effortless
const expertHistory: StudentSkillHistory = {
skills: {
'basic.directAddition': { skillId: 'basic.directAddition', masteryLevel: 'effortless' },
'basic.heavenBead': { skillId: 'basic.heavenBead', masteryLevel: 'effortless' },
'basic.simpleCombinations': {
skillId: 'basic.simpleCombinations',
masteryLevel: 'effortless',
},
'tenComplements.9=10-1': { skillId: 'tenComplements.9=10-1', masteryLevel: 'effortless' },
'tenComplements.8=10-2': { skillId: 'tenComplements.8=10-2', masteryLevel: 'effortless' },
},
}
const calculator = createSkillCostCalculator(expertHistory)
// Generate multiple problems with higher budget
let hasComplexSkill = false
for (let i = 0; i < 20; i++) {
const problem = generateSingleProblem({
constraints: {
numberRange: { min: 1, max: 9 },
maxTerms: 5,
problemCount: 1,
maxComplexityBudgetPerTerm: 6, // Higher budget
},
requiredSkills: fullSkillSet,
costCalculator: calculator,
attempts: 50,
})
if (problem) {
const hasTenComplement = problem.requiredSkills.some((s) => s.includes('tenComplements'))
if (hasTenComplement) {
hasComplexSkill = true
break
}
}
}
// With a higher budget and expert status, ten complements should sometimes appear
// (This test may be flaky due to randomness, but with 20 attempts it should succeed)
expect(hasComplexSkill).toBe(true)
})
it('should work without budget constraint (backward compatibility)', () => {
// Without costCalculator, should work as before
const problem = generateSingleProblem(
{
numberRange: { min: 1, max: 9 },
maxTerms: 5,
problemCount: 1,
},
fullSkillSet
)
expect(problem).not.toBeNull()
})
it('should work with new options API', () => {
const problem = generateSingleProblem({
constraints: {
numberRange: { min: 1, max: 9 },
maxTerms: 5,
problemCount: 1,
},
requiredSkills: fullSkillSet,
attempts: 100,
})
expect(problem).not.toBeNull()
})
})
})

View File

@ -0,0 +1,217 @@
import { describe, it, expect } from 'vitest'
import {
BASE_SKILL_COMPLEXITY,
MASTERY_MULTIPLIERS,
getBaseComplexity,
createSkillCostCalculator,
buildStudentSkillHistory,
dbMasteryToState,
type StudentSkillHistory,
} from '../skillComplexity'
describe('BASE_SKILL_COMPLEXITY', () => {
it('should have costs for all 34 skills', () => {
expect(Object.keys(BASE_SKILL_COMPLEXITY).length).toBe(34)
})
it('should have base cost 1 for basic and five complement skills', () => {
expect(BASE_SKILL_COMPLEXITY['basic.directAddition']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['basic.directSubtraction']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['basic.heavenBead']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['basic.heavenBeadSubtraction']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['basic.simpleCombinations']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['basic.simpleCombinationsSub']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplements.4=5-1']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplements.3=5-2']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplements.2=5-3']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplements.1=5-4']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplementsSub.-4=-5+1']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplementsSub.-3=-5+2']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplementsSub.-2=-5+3']).toBe(1)
expect(BASE_SKILL_COMPLEXITY['fiveComplementsSub.-1=-5+4']).toBe(1)
})
it('should have base cost 2 for ten complement skills', () => {
expect(BASE_SKILL_COMPLEXITY['tenComplements.9=10-1']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.8=10-2']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.7=10-3']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.6=10-4']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.5=10-5']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.4=10-6']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.3=10-7']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.2=10-8']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplements.1=10-9']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-9=+1-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-8=+2-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-7=+3-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-6=+4-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-5=+5-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-4=+6-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-3=+7-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-2=+8-10']).toBe(2)
expect(BASE_SKILL_COMPLEXITY['tenComplementsSub.-1=+9-10']).toBe(2)
})
it('should have base cost 3 for advanced cascading skills', () => {
expect(BASE_SKILL_COMPLEXITY['advanced.cascadingCarry']).toBe(3)
expect(BASE_SKILL_COMPLEXITY['advanced.cascadingBorrow']).toBe(3)
})
})
describe('MASTERY_MULTIPLIERS', () => {
it('should have correct multipliers', () => {
expect(MASTERY_MULTIPLIERS.effortless).toBe(1)
expect(MASTERY_MULTIPLIERS.fluent).toBe(2)
expect(MASTERY_MULTIPLIERS.practicing).toBe(3)
expect(MASTERY_MULTIPLIERS.learning).toBe(4)
})
})
describe('getBaseComplexity', () => {
it('should return base complexity for known skills', () => {
expect(getBaseComplexity('basic.directAddition')).toBe(1)
expect(getBaseComplexity('tenComplements.9=10-1')).toBe(2)
expect(getBaseComplexity('advanced.cascadingCarry')).toBe(3)
})
it('should return 1 for unknown skills', () => {
expect(getBaseComplexity('unknown.skill')).toBe(1)
})
})
describe('createSkillCostCalculator', () => {
it('should calculate cost based on mastery level', () => {
const history: StudentSkillHistory = {
skills: {
'basic.directAddition': { skillId: 'basic.directAddition', masteryLevel: 'effortless' },
'tenComplements.9=10-1': { skillId: 'tenComplements.9=10-1', masteryLevel: 'learning' },
},
}
const calculator = createSkillCostCalculator(history)
// directAddition: base 1 × effortless 1 = 1
expect(calculator.calculateSkillCost('basic.directAddition')).toBe(1)
// tenComplement: base 2 × learning 4 = 8
expect(calculator.calculateSkillCost('tenComplements.9=10-1')).toBe(8)
})
it('should treat unknown skills as learning', () => {
const history: StudentSkillHistory = { skills: {} }
const calculator = createSkillCostCalculator(history)
// Unknown skill: base 1 × learning 4 = 4
expect(calculator.calculateSkillCost('basic.directAddition')).toBe(4)
})
it('should calculate term cost as sum of skill costs', () => {
const history: StudentSkillHistory = {
skills: {
'basic.directAddition': { skillId: 'basic.directAddition', masteryLevel: 'effortless' },
'fiveComplements.4=5-1': { skillId: 'fiveComplements.4=5-1', masteryLevel: 'fluent' },
'tenComplements.9=10-1': { skillId: 'tenComplements.9=10-1', masteryLevel: 'practicing' },
},
}
const calculator = createSkillCostCalculator(history)
const termCost = calculator.calculateTermCost([
'basic.directAddition', // 1 × 1 = 1
'fiveComplements.4=5-1', // 1 × 2 = 2
'tenComplements.9=10-1', // 2 × 3 = 6
])
expect(termCost).toBe(9)
})
it('should return 0 for empty skill list', () => {
const history: StudentSkillHistory = { skills: {} }
const calculator = createSkillCostCalculator(history)
expect(calculator.calculateTermCost([])).toBe(0)
})
it('should return correct mastery state via getMasteryState', () => {
const history: StudentSkillHistory = {
skills: {
'basic.directAddition': { skillId: 'basic.directAddition', masteryLevel: 'effortless' },
'tenComplements.9=10-1': { skillId: 'tenComplements.9=10-1', masteryLevel: 'practicing' },
},
}
const calculator = createSkillCostCalculator(history)
expect(calculator.getMasteryState('basic.directAddition')).toBe('effortless')
expect(calculator.getMasteryState('tenComplements.9=10-1')).toBe('practicing')
expect(calculator.getMasteryState('unknown.skill')).toBe('learning')
})
})
describe('dbMasteryToState', () => {
it('should map learning to learning', () => {
expect(dbMasteryToState('learning')).toBe('learning')
})
it('should map practicing to practicing', () => {
expect(dbMasteryToState('practicing')).toBe('practicing')
})
it('should map recently mastered to fluent', () => {
expect(dbMasteryToState('mastered', 10)).toBe('fluent')
expect(dbMasteryToState('mastered', 30)).toBe('fluent')
})
it('should map long-ago mastered to effortless', () => {
expect(dbMasteryToState('mastered', 31)).toBe('effortless')
expect(dbMasteryToState('mastered', 90)).toBe('effortless')
})
it('should default to fluent when no days provided', () => {
expect(dbMasteryToState('mastered')).toBe('fluent')
})
})
describe('buildStudentSkillHistory', () => {
it('should build history from database records', () => {
const records = [
{
skillId: 'basic.directAddition',
masteryLevel: 'mastered' as const,
lastPracticedAt: new Date('2024-01-01'),
},
{
skillId: 'tenComplements.9=10-1',
masteryLevel: 'practicing' as const,
lastPracticedAt: null,
},
]
const referenceDate = new Date('2024-03-01') // 60 days later
const history = buildStudentSkillHistory(records, referenceDate)
expect(history.skills['basic.directAddition'].masteryLevel).toBe('effortless')
expect(history.skills['tenComplements.9=10-1'].masteryLevel).toBe('practicing')
})
it('should handle empty records', () => {
const history = buildStudentSkillHistory([])
expect(history.skills).toEqual({})
})
it('should use current date as default reference', () => {
const recentDate = new Date()
recentDate.setDate(recentDate.getDate() - 5) // 5 days ago
const records = [
{
skillId: 'basic.directAddition',
masteryLevel: 'mastered' as const,
lastPracticedAt: recentDate,
},
]
const history = buildStudentSkillHistory(records)
// 5 days ago = fluent (< 30 days)
expect(history.skills['basic.directAddition'].masteryLevel).toBe('fluent')
})
})

View File

@ -1,4 +1,9 @@
import type { GenerationTrace, GenerationTraceStep } from '@/db/schema/session-plans'
import type { PracticeStep, SkillSet } from '../types/tutorial'
import type { SkillCostCalculator } from './skillComplexity'
// Re-export trace types for consumers that import from this file
export type { GenerationTrace, GenerationTraceStep }
export interface GeneratedProblem {
id: string
@ -15,8 +20,18 @@ export interface ProblemConstraints {
numberRange: { min: number; max: number }
maxSum?: number
minSum?: number
minTerms?: number
maxTerms: number
problemCount: number
/**
* Maximum complexity budget per term.
*
* Each term's skills are costed using the SkillCostCalculator,
* which factors in both base skill complexity and student mastery.
*
* If set, terms with total cost > budget are rejected during generation.
*/
maxComplexityBudgetPerTerm?: number
}
/**
@ -49,28 +64,8 @@ export function analyzeRequiredSkills(terms: number[], _finalSum: number): strin
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[]
}
// GenerationTrace and GenerationTraceStep are imported from @/db/schema/session-plans
// and re-exported above for backward compatibility
/**
* Generates a human-readable explanation for a single step
@ -152,7 +147,7 @@ function generateStepExplanation(
* Analyzes skills needed for a single addition step: currentValue + term = newValue
* Also detects cascading carries (when a carry propagates across 2+ columns).
*/
function analyzeStepSkills(currentValue: number, term: number, newValue: number): string[] {
export function analyzeStepSkills(currentValue: number, term: number, newValue: number): string[] {
const skills: string[] = []
// Work column by column from right to left
@ -501,27 +496,69 @@ export function problemMatchesSkills(
return true
}
/**
* Options for generating a single problem
*/
export interface GenerateProblemOptions {
constraints: ProblemConstraints
requiredSkills: SkillSet
targetSkills?: Partial<SkillSet>
forbiddenSkills?: Partial<SkillSet>
/** Student-aware cost calculator for budget enforcement */
costCalculator?: SkillCostCalculator
/** Number of attempts before giving up (default: 100) */
attempts?: number
}
/**
* Generates a single sequential addition problem that matches the given constraints and skills
*/
export function generateSingleProblem(
constraints: ProblemConstraints,
requiredSkills: SkillSet,
constraintsOrOptions: ProblemConstraints | GenerateProblemOptions,
requiredSkills?: SkillSet,
targetSkills?: Partial<SkillSet>,
forbiddenSkills?: Partial<SkillSet>,
attempts: number = 100
): GeneratedProblem | null {
for (let attempt = 0; attempt < attempts; attempt++) {
// Generate random number of terms (3 to 5 as specified)
const termCount = Math.floor(Math.random() * 3) + 3 // 3-5 terms
// Support both old and new API
let constraints: ProblemConstraints
let _requiredSkills: SkillSet
let _targetSkills: Partial<SkillSet> | undefined
let _forbiddenSkills: Partial<SkillSet> | undefined
let _attempts: number
let costCalculator: SkillCostCalculator | undefined
if ('constraints' in constraintsOrOptions) {
// New options-based API
constraints = constraintsOrOptions.constraints
_requiredSkills = constraintsOrOptions.requiredSkills
_targetSkills = constraintsOrOptions.targetSkills
_forbiddenSkills = constraintsOrOptions.forbiddenSkills
_attempts = constraintsOrOptions.attempts ?? 100
costCalculator = constraintsOrOptions.costCalculator
} else {
// Old positional API (backward compatibility)
constraints = constraintsOrOptions
_requiredSkills = requiredSkills!
_targetSkills = targetSkills
_forbiddenSkills = forbiddenSkills
_attempts = attempts
}
for (let attempt = 0; attempt < _attempts; attempt++) {
// Generate random number of terms within the specified range
const minTerms = constraints.minTerms ?? 3
const maxTerms = constraints.maxTerms
const termCount = Math.floor(Math.random() * (maxTerms - minTerms + 1)) + minTerms
// Generate the sequence of numbers to add (now returns trace with provenance)
const sequenceResult = generateSequence(
constraints,
termCount,
requiredSkills,
targetSkills,
forbiddenSkills
_requiredSkills,
_targetSkills,
_forbiddenSkills,
costCalculator
)
if (!sequenceResult) continue // Failed to generate valid sequence
@ -555,7 +592,7 @@ export function generateSingleProblem(
}
// Check if problem matches skill requirements
if (problemMatchesSkills(problem, requiredSkills, targetSkills, forbiddenSkills)) {
if (problemMatchesSkills(problem, _requiredSkills, _targetSkills, _forbiddenSkills)) {
return problem
}
}
@ -592,7 +629,8 @@ function generateSequence(
termCount: number,
requiredSkills: SkillSet,
targetSkills?: Partial<SkillSet>,
forbiddenSkills?: Partial<SkillSet>
forbiddenSkills?: Partial<SkillSet>,
costCalculator?: SkillCostCalculator
): SequenceResult | null {
const terms: number[] = []
const steps: GenerationTraceStep[] = []
@ -612,12 +650,13 @@ function generateSequence(
targetSkills,
forbiddenSkills,
i === termCount - 1, // isLastTerm
allowSubtraction
allowSubtraction,
costCalculator
)
if (result === null) return null // Couldn't find valid term
const { term, skillsUsed, isSubtraction } = result
const { term, skillsUsed, isSubtraction, complexityCost } = result
const newValue = isSubtraction ? currentValue - term : currentValue + term
// Build trace step with the skills the generator computed
@ -640,6 +679,7 @@ function generateSequence(
accumulatedAfter: newValue,
skillsUsed,
explanation,
complexityCost,
})
// Store the signed term for the problem
@ -654,6 +694,7 @@ function generateSequence(
answer: currentValue,
steps,
allSkills: [...new Set(steps.flatMap((s) => s.skillsUsed))],
budgetConstraint: constraints.maxComplexityBudgetPerTerm,
},
}
}
@ -663,6 +704,8 @@ interface TermWithSkills {
term: number
skillsUsed: string[]
isSubtraction: boolean
/** Complexity cost (if calculator was provided) */
complexityCost?: number
}
/**
@ -677,10 +720,12 @@ function findValidNextTermWithTrace(
targetSkills?: Partial<SkillSet>,
forbiddenSkills?: Partial<SkillSet>,
isLastTerm: boolean = false,
allowSubtraction: boolean = false
allowSubtraction: boolean = false,
costCalculator?: SkillCostCalculator
): TermWithSkills | null {
const { min, max } = constraints.numberRange
const candidates: TermWithSkills[] = []
const maxBudget = constraints.maxComplexityBudgetPerTerm
// Try each possible ADDITION term value
for (let term = min; term <= max; term++) {
@ -700,9 +745,22 @@ function findValidNextTermWithTrace(
return true
})
if (usesValidSkills) {
candidates.push({ term, skillsUsed: stepSkills, isSubtraction: false })
if (!usesValidSkills) continue
// Calculate complexity cost (if calculator provided)
const termCost = costCalculator ? costCalculator.calculateTermCost(stepSkills) : undefined
// Check complexity budget (if calculator and budget are provided)
if (maxBudget !== undefined && termCost !== undefined) {
if (termCost > maxBudget) continue // Skip - too complex for this student
}
candidates.push({
term,
skillsUsed: stepSkills,
isSubtraction: false,
complexityCost: termCost,
})
}
// Try each possible SUBTRACTION term value (if allowed)
@ -727,9 +785,22 @@ function findValidNextTermWithTrace(
return true
})
if (usesValidSkills) {
candidates.push({ term, skillsUsed: stepSkills, isSubtraction: true })
if (!usesValidSkills) continue
// Calculate complexity cost (if calculator provided)
const termCost = costCalculator ? costCalculator.calculateTermCost(stepSkills) : undefined
// Check complexity budget (if calculator and budget are provided)
if (maxBudget !== undefined && termCost !== undefined) {
if (termCost > maxBudget) continue // Skip - too complex for this student
}
candidates.push({
term,
skillsUsed: stepSkills,
isSubtraction: true,
complexityCost: termCost,
})
}
}

View File

@ -0,0 +1,266 @@
/**
* Skill Complexity Budget System
*
* Cost = baseCost × masteryMultiplier
*
* This architecture separates:
* 1. BASE_SKILL_COMPLEXITY - intrinsic mechanical complexity (constant)
* 2. SkillCostCalculator - student-aware cost calculation (pluggable)
*/
// =============================================================================
// Types
// =============================================================================
/**
* Mastery state affects how much cognitive load a skill requires
*/
export type MasteryState = 'effortless' | 'fluent' | 'practicing' | 'learning'
/**
* Multipliers for each mastery state
*/
export const MASTERY_MULTIPLIERS: Record<MasteryState, number> = {
effortless: 1, // Automatic, no thought required
fluent: 2, // Solid but needs some attention
practicing: 3, // Currently working on, needs focus
learning: 4, // Just introduced, maximum effort
}
/**
* Information about a student's relationship with a skill
*/
export interface StudentSkillState {
skillId: string
masteryLevel: MasteryState
// Future extensions:
// lastPracticedAt?: Date
// recentAccuracy?: number
// consecutiveCorrect?: number
// daysSinceMastery?: number
}
/**
* Student skill history - all skills and their states
*/
export interface StudentSkillHistory {
skills: Record<string, StudentSkillState>
}
/**
* Interface for calculating skill cost for a student
* This abstraction allows swapping implementations later
*/
export interface SkillCostCalculator {
/**
* Calculate the effective cost of a skill for this student
*/
calculateSkillCost(skillId: string): number
/**
* Calculate total cost for a set of skills (a term)
*/
calculateTermCost(skillIds: string[]): number
/**
* Get mastery state for a skill (useful for debug UI)
*/
getMasteryState(skillId: string): MasteryState
}
// =============================================================================
// Base Complexity (Intrinsic to skill mechanics)
// =============================================================================
/**
* Base complexity for each skill - reflects intrinsic mechanical difficulty
*
* 0 = Trivial (basic bead movements, no mental calculation)
* 1 = Single complement (one mental substitution: +4 = +5-1)
* 2 = Cross-column (must track carry/borrow across place values)
* 3 = Multi-column cascading (must track propagation across 2+ columns)
*/
export const BASE_SKILL_COMPLEXITY: Record<string, number> = {
// Base 0: Trivial operations - just moving beads, no mental math
'basic.directAddition': 0,
'basic.directSubtraction': 0,
'basic.heavenBead': 0,
'basic.heavenBeadSubtraction': 0,
'basic.simpleCombinations': 0,
'basic.simpleCombinationsSub': 0,
// Base 1: Five complements - single mental substitution
'fiveComplements.4=5-1': 1,
'fiveComplements.3=5-2': 1,
'fiveComplements.2=5-3': 1,
'fiveComplements.1=5-4': 1,
'fiveComplementsSub.-4=-5+1': 1,
'fiveComplementsSub.-3=-5+2': 1,
'fiveComplementsSub.-2=-5+3': 1,
'fiveComplementsSub.-1=-5+4': 1,
// Base 2: Ten complements - cross-column operations
'tenComplements.9=10-1': 2,
'tenComplements.8=10-2': 2,
'tenComplements.7=10-3': 2,
'tenComplements.6=10-4': 2,
'tenComplements.5=10-5': 2,
'tenComplements.4=10-6': 2,
'tenComplements.3=10-7': 2,
'tenComplements.2=10-8': 2,
'tenComplements.1=10-9': 2,
'tenComplementsSub.-9=+1-10': 2,
'tenComplementsSub.-8=+2-10': 2,
'tenComplementsSub.-7=+3-10': 2,
'tenComplementsSub.-6=+4-10': 2,
'tenComplementsSub.-5=+5-10': 2,
'tenComplementsSub.-4=+6-10': 2,
'tenComplementsSub.-3=+7-10': 2,
'tenComplementsSub.-2=+8-10': 2,
'tenComplementsSub.-1=+9-10': 2,
// Base 3: Multi-column cascading
'advanced.cascadingCarry': 3,
'advanced.cascadingBorrow': 3,
}
/**
* Get base complexity for a skill (defaults to 1 for unknown skills)
*/
export function getBaseComplexity(skillId: string): number {
return BASE_SKILL_COMPLEXITY[skillId] ?? 1
}
// =============================================================================
// Default Implementation: Mastery-Level Based Calculator
// =============================================================================
/**
* Creates a skill cost calculator based on student's skill history
*
* This is the default implementation that uses mastery levels.
* Can be replaced with more sophisticated implementations later.
*/
export function createSkillCostCalculator(
studentHistory: StudentSkillHistory
): SkillCostCalculator {
return {
calculateSkillCost(skillId: string): number {
const baseCost = getBaseComplexity(skillId)
const multiplier = getMasteryMultiplier(skillId, studentHistory)
return baseCost * multiplier
},
calculateTermCost(skillIds: string[]): number {
return skillIds.reduce((total, skillId) => {
return total + this.calculateSkillCost(skillId)
}, 0)
},
getMasteryState(skillId: string): MasteryState {
const skillState = studentHistory.skills[skillId]
if (!skillState) {
return 'learning'
}
return skillState.masteryLevel
},
}
}
/**
* Get mastery multiplier for a skill based on student history
*/
function getMasteryMultiplier(skillId: string, history: StudentSkillHistory): number {
const skillState = history.skills[skillId]
// Unknown skill = treat as learning (maximum cost)
if (!skillState) {
return MASTERY_MULTIPLIERS.learning
}
return MASTERY_MULTIPLIERS[skillState.masteryLevel]
}
// =============================================================================
// Utility Functions
// =============================================================================
/**
* Convert database mastery level to our MasteryState
*
* Database has: 'learning' | 'practicing' | 'mastered'
* We add 'effortless' for long-mastered skills
*/
export function dbMasteryToState(
dbLevel: 'learning' | 'practicing' | 'mastered',
daysSinceMastery?: number
): MasteryState {
if (dbLevel === 'learning') return 'learning'
if (dbLevel === 'practicing') return 'practicing'
// Mastered - check how long ago
if (daysSinceMastery !== undefined && daysSinceMastery > 30) {
return 'effortless'
}
return 'fluent'
}
/**
* Build StudentSkillHistory from database records
*/
export function buildStudentSkillHistory(
dbRecords: Array<{
skillId: string
masteryLevel: 'learning' | 'practicing' | 'mastered'
lastPracticedAt?: Date | null
// Could add more fields for future sophistication
}>,
referenceDate: Date = new Date()
): StudentSkillHistory {
const skills: Record<string, StudentSkillState> = {}
for (const record of dbRecords) {
const daysSinceMastery = record.lastPracticedAt
? Math.floor(
(referenceDate.getTime() - record.lastPracticedAt.getTime()) / (1000 * 60 * 60 * 24)
)
: undefined
skills[record.skillId] = {
skillId: record.skillId,
masteryLevel: dbMasteryToState(record.masteryLevel, daysSinceMastery),
}
}
return { skills }
}
// =============================================================================
// Budget Defaults
// =============================================================================
/**
* Default complexity budgets for different contexts
*
* These represent the MAXIMUM total cost allowed per term.
*
* For a beginner (all skills at learning/4x multiplier):
* budget 4 = allows 1 base-1 skill (1×4=4)
* budget 8 = allows 2 base-1 skills or 1 base-2 skill
*
* For advanced student (all skills effortless/1x):
* budget 4 = allows 4 base-1 skills or 2 base-2 skills
*/
export const DEFAULT_COMPLEXITY_BUDGETS = {
/** No limit - full complexity allowed */
unlimited: Number.POSITIVE_INFINITY,
/** Use abacus mode - high budget (physical abacus reduces cognitive load) */
useAbacus: 12,
/** Visualization beginner - conservative */
visualizationDefault: 6,
/** Linear mode */
linearDefault: 8,
}