From 5d61de4bf6273db41f080b86b059c817481f1cc9 Mon Sep 17 00:00:00 2001 From: Thomas Hallock Date: Wed, 10 Dec 2025 20:18:20 -0600 Subject: [PATCH] feat(practice): add complexity budget system and toggleable session parts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/web/.claude/settings.local.json | 14 +- apps/web/.storybook/preview.tsx | 14 +- apps/web/drizzle/meta/0028_snapshot.json | 112 ++----- apps/web/drizzle/meta/_journal.json | 2 +- .../[playerId]/sessions/plans/route.ts | 28 +- .../[studentId]/configure/ConfigureClient.tsx | 255 ++++++++++++--- .../src/components/practice/ActiveSession.tsx | 3 + .../practice/ManualSkillSelector.stories.tsx | 37 +++ .../practice/ManualSkillSelector.tsx | 136 ++++++++ .../components/practice/ProblemDebugPanel.tsx | 99 ++++++ .../components/practice/VerticalProblem.tsx | 80 +++++ apps/web/src/db/schema/session-plans.ts | 61 +++- apps/web/src/hooks/useSessionPlan.ts | 17 +- apps/web/src/lib/curriculum/index.ts | 1 + .../src/lib/curriculum/problem-generator.ts | 2 + .../web/src/lib/curriculum/session-planner.ts | 172 ++++++---- .../__tests__/problemGenerator.budget.test.ts | 306 ++++++++++++++++++ .../utils/__tests__/skillComplexity.test.ts | 217 +++++++++++++ apps/web/src/utils/problemGenerator.ts | 151 ++++++--- apps/web/src/utils/skillComplexity.ts | 266 +++++++++++++++ 20 files changed, 1738 insertions(+), 235 deletions(-) create mode 100644 apps/web/src/utils/__tests__/problemGenerator.budget.test.ts create mode 100644 apps/web/src/utils/__tests__/skillComplexity.test.ts create mode 100644 apps/web/src/utils/skillComplexity.ts diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index e75e01e8..3cefc118 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -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"] } diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx index c6223d1e..09e40590 100644 --- a/apps/web/.storybook/preview.tsx +++ b/apps/web/.storybook/preview.tsx @@ -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) => ( - + + + + + ), ], diff --git a/apps/web/drizzle/meta/0028_snapshot.json b/apps/web/drizzle/meta/0028_snapshot.json index 361a3ac1..2954ac41 100644 --- a/apps/web/drizzle/meta/0028_snapshot.json +++ b/apps/web/drizzle/meta/0028_snapshot.json @@ -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": {} } -} \ No newline at end of file +} diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 04af3c33..d495fd28 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -206,4 +206,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts b/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts index 6b7d1b18..45aaf45e 100644 --- a/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts +++ b/apps/web/src/app/api/curriculum/[playerId]/sessions/plans/route.ts @@ -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) diff --git a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx index d7db68c0..f3bb1dee 100644 --- a/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx +++ b/apps/web/src/app/practice/[studentId]/configure/ConfigureClient.tsx @@ -117,41 +117,78 @@ function groupSkillsByCategory(skillIds: string[]): Map { } /** - * 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({ + 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({ + {/* Terms per Problem Selector */} +
+ +
+ {[3, 4, 5, 6, 7, 8].map((terms) => ( + + ))} +
+
+ 🧮 Abacus: up to {abacusMaxTerms} numbers  â€¢  🧠 Visualize: up to{' '} + {visualizationMaxTerms} numbers (75%) +
+
+ {/* Live Preview - Summary */}
- Session Structure + Session Structure{' '} + + (tap to toggle) +
{PART_INFO.map((partInfo, index) => { const partEstimate = estimates.parts[index] + const isEnabled = enabledParts[partInfo.type] const colors = getPartTypeColors(partInfo.type, isDark) return ( -
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', + }, })} > {partInfo.emoji} @@ -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} +
+
+ {partInfo.description}
-
- {partEstimate.problems} problems -
-
~{partEstimate.minutes} min
+ {isEnabled ? ( + <> +
+ {partEstimate.problems} problems +
+
~{partEstimate.minutes} min
+ + ) : ( +
skipped
+ )}
-
+ ) })} diff --git a/apps/web/src/components/practice/ActiveSession.tsx b/apps/web/src/components/practice/ActiveSession.tsx index 6f109154..e007a284 100644 --- a/apps/web/src/components/practice/ActiveSession.tsx +++ b/apps/web/src/components/practice/ActiveSession.tsx @@ -863,6 +863,7 @@ export function ActiveSession({ isCompleted={true} correctAnswer={outgoingAttempt.problem.answer} size="large" + generationTrace={outgoingAttempt.problem.generationTrace} /> {/* Feedback stays with outgoing problem */}
) : undefined } + generationTrace={attempt.problem.generationTrace} + complexityBudget={currentSlot?.constraints?.maxComplexityBudgetPerTerm} /> ) : ( , } + +/** + * 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: () => ( + + ), + 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. + `, + }, + }, + }, +} diff --git a/apps/web/src/components/practice/ManualSkillSelector.tsx b/apps/web/src/components/practice/ManualSkillSelector.tsx index 0449149f..dbd15db4 100644 --- a/apps/web/src/components/practice/ManualSkillSelector.tsx +++ b/apps/web/src/components/practice/ManualSkillSelector.tsx @@ -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 = { + 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 ( + + {style.label} + + ) +} + +/** + * ComplexityLegend - Shows explanation of complexity badges + */ +function ComplexityLegend({ isDark }: { isDark: boolean }) { + return ( +
+ Complexity: + + + 1★ + + Simple + + + + 2★ + + Cross-column + + + + 3★ + + Cascading + +
+ ) +} + /** * Book preset mappings (SAI Abacus Mind Math levels) */ @@ -403,6 +534,9 @@ export function ManualSkillSelector({
+ {/* Complexity Legend */} + + {/* Skills Accordion */} + {skillName} diff --git a/apps/web/src/components/practice/ProblemDebugPanel.tsx b/apps/web/src/components/practice/ProblemDebugPanel.tsx index 5f8612ca..7918bb89 100644 --- a/apps/web/src/components/practice/ProblemDebugPanel.tsx +++ b/apps/web/src/components/practice/ProblemDebugPanel.tsx @@ -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({ + {/* Complexity Budget Info */} +
+
+ Complexity Budget + {budgetConstraint !== undefined ? ( + max {budgetConstraint}/term + ) : ( + no limit + )} +
+ {trace ? ( +
+ {trace.steps.map((step, i) => { + const cost = step.complexityCost + const isOverBudget = + budgetConstraint !== undefined && cost !== undefined && cost > budgetConstraint + return ( +
+ + {step.termAdded >= 0 + ? `+ ${step.termAdded}` + : `- ${Math.abs(step.termAdded)}`} + + + {cost !== undefined ? `${cost}` : '—'} + + + {step.skillsUsed.length > 0 ? step.skillsUsed.join(', ') : '(none)'} + +
+ ) + })} +
+ ) : ( +
+ No generation trace available +
+ )} +
+ {/* Full JSON */}
 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}
               
             ))}
+
+            {/* 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 (
+                  
+ {/* Total complexity cost badge */} + + {cost !== undefined ? cost : baseCostSum} + + + {/* Skills list with individual base costs */} + + {skills.length > 0 + ? skills + .map((s) => { + const baseCost = getBaseComplexity(s) + const shortName = s.split('.').pop() + return `${shortName}(${baseCost})` + }) + .join(', ') + : '(none)'} + +
+ ) + })()} ) })} diff --git a/apps/web/src/db/schema/session-plans.ts b/apps/web/src/db/schema/session-plans.ts index 91ac61d8..e51bd1db 100644 --- a/apps/web/src/db/schema/session-plans.ts +++ b/apps/web/src/db/schema/session-plans.ts @@ -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 diff --git a/apps/web/src/hooks/useSessionPlan.ts b/apps/web/src/hooks/useSessionPlan.ts index 544cd234..70651761 100644 --- a/apps/web/src/hooks/useSessionPlan.ts +++ b/apps/web/src/hooks/useSessionPlan.ts @@ -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 { 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(() => ({})) diff --git a/apps/web/src/lib/curriculum/index.ts b/apps/web/src/lib/curriculum/index.ts index 2af4bece..11dc12b5 100644 --- a/apps/web/src/lib/curriculum/index.ts +++ b/apps/web/src/lib/curriculum/index.ts @@ -58,6 +58,7 @@ export { abandonSessionPlan, approveSessionPlan, completeSessionPlanEarly, + type EnabledParts, type GenerateSessionPlanOptions, generateSessionPlan, getActiveSessionPlan, diff --git a/apps/web/src/lib/curriculum/problem-generator.ts b/apps/web/src/lib/curriculum/problem-generator.ts index 316d0cd3..b04c5251 100644 --- a/apps/web/src/lib/curriculum/problem-generator.ts +++ b/apps/web/src/lib/curriculum/problem-generator.ts @@ -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, } } diff --git a/apps/web/src/lib/curriculum/session-planner.ts b/apps/web/src/lib/curriculum/session-planner.ts index c847caec..ccbd05d5 100644 --- a/apps/web/src/lib/curriculum/session-planner.ts +++ b/apps/web/src/lib/curriculum/session-planner.ts @@ -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 + /** 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 { - 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, 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 { // ============================================================================ /** - * 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, - 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 diff --git a/apps/web/src/utils/__tests__/problemGenerator.budget.test.ts b/apps/web/src/utils/__tests__/problemGenerator.budget.test.ts new file mode 100644 index 00000000..20557082 --- /dev/null +++ b/apps/web/src/utils/__tests__/problemGenerator.budget.test.ts @@ -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() + }) + }) +}) diff --git a/apps/web/src/utils/__tests__/skillComplexity.test.ts b/apps/web/src/utils/__tests__/skillComplexity.test.ts new file mode 100644 index 00000000..5a4e43b6 --- /dev/null +++ b/apps/web/src/utils/__tests__/skillComplexity.test.ts @@ -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') + }) +}) diff --git a/apps/web/src/utils/problemGenerator.ts b/apps/web/src/utils/problemGenerator.ts index 74c42c0f..1cf5b949 100644 --- a/apps/web/src/utils/problemGenerator.ts +++ b/apps/web/src/utils/problemGenerator.ts @@ -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 + forbiddenSkills?: Partial + /** 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, forbiddenSkills?: Partial, 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 | undefined + let _forbiddenSkills: Partial | 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, - forbiddenSkills?: Partial + forbiddenSkills?: Partial, + 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, forbiddenSkills?: Partial, 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, + }) } } diff --git a/apps/web/src/utils/skillComplexity.ts b/apps/web/src/utils/skillComplexity.ts new file mode 100644 index 00000000..c12ea415 --- /dev/null +++ b/apps/web/src/utils/skillComplexity.ts @@ -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 = { + 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 +} + +/** + * 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 = { + // 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 = {} + + 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, +}