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:
parent
9159608dcd
commit
5d61de4bf6
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -206,4 +206,4 @@
|
|||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 • 🧠 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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => ({}))
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export {
|
|||
abandonSessionPlan,
|
||||
approveSessionPlan,
|
||||
completeSessionPlanEarly,
|
||||
type EnabledParts,
|
||||
type GenerateSessionPlanOptions,
|
||||
generateSessionPlan,
|
||||
getActiveSessionPlan,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
Loading…
Reference in New Issue