346 lines
11 KiB
TypeScript
346 lines
11 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Generate A/B mastery trajectory data for all skills.
|
|
* Runs simulations directly without vitest overhead.
|
|
*
|
|
* Usage: npx tsx scripts/generateTrajectoryData.ts
|
|
* Output: public/data/ab-mastery-trajectories.json
|
|
*/
|
|
|
|
import fs from 'fs'
|
|
import path from 'path'
|
|
import Database from 'better-sqlite3'
|
|
import { drizzle } from 'drizzle-orm/better-sqlite3'
|
|
import * as schema from '../src/db/schema'
|
|
import { SeededRandom } from '../src/test/journey-simulator/SeededRandom'
|
|
import { SimulatedStudent } from '../src/test/journey-simulator/SimulatedStudent'
|
|
import type { StudentProfile, JourneyConfig } from '../src/test/journey-simulator/types'
|
|
|
|
// All skills in the curriculum
|
|
const ALL_SKILLS = [
|
|
// Basic skills (6)
|
|
'basic.directAddition',
|
|
'basic.directSubtraction',
|
|
'basic.heavenBead',
|
|
'basic.heavenBeadSubtraction',
|
|
'basic.simpleCombinations',
|
|
'basic.simpleCombinationsSub',
|
|
// Five complements addition (4)
|
|
'fiveComplements.4=5-1',
|
|
'fiveComplements.3=5-2',
|
|
'fiveComplements.2=5-3',
|
|
'fiveComplements.1=5-4',
|
|
// Five complements subtraction (4)
|
|
'fiveComplementsSub.-4=-5+1',
|
|
'fiveComplementsSub.-3=-5+2',
|
|
'fiveComplementsSub.-2=-5+3',
|
|
'fiveComplementsSub.-1=-5+4',
|
|
// Ten complements addition (9)
|
|
'tenComplements.9=10-1',
|
|
'tenComplements.8=10-2',
|
|
'tenComplements.7=10-3',
|
|
'tenComplements.6=10-4',
|
|
'tenComplements.5=10-5',
|
|
'tenComplements.4=10-6',
|
|
'tenComplements.3=10-7',
|
|
'tenComplements.2=10-8',
|
|
'tenComplements.1=10-9',
|
|
// Ten complements subtraction (9)
|
|
'tenComplementsSub.-9=+1-10',
|
|
'tenComplementsSub.-8=+2-10',
|
|
'tenComplementsSub.-7=+3-10',
|
|
'tenComplementsSub.-6=+4-10',
|
|
'tenComplementsSub.-5=+5-10',
|
|
'tenComplementsSub.-4=+6-10',
|
|
'tenComplementsSub.-3=+7-10',
|
|
'tenComplementsSub.-2=+8-10',
|
|
'tenComplementsSub.-1=+9-10',
|
|
// Advanced (2)
|
|
'advanced.cascadingCarry',
|
|
'advanced.cascadingBorrow',
|
|
]
|
|
|
|
const OUTPUT_PATH = path.join(process.cwd(), 'public/data/ab-mastery-trajectories.json')
|
|
|
|
interface TrajectoryPoint {
|
|
session: number
|
|
mastery: number
|
|
}
|
|
|
|
interface SkillTrajectory {
|
|
adaptive: TrajectoryPoint[]
|
|
classic: TrajectoryPoint[]
|
|
sessionsTo50Adaptive: number | null
|
|
sessionsTo50Classic: number | null
|
|
sessionsTo80Adaptive: number | null
|
|
sessionsTo80Classic: number | null
|
|
}
|
|
|
|
// Simplified journey runner that just tracks mastery over sessions
|
|
function runSimplifiedJourney(
|
|
skillId: string,
|
|
profile: StudentProfile,
|
|
sessionCount: number,
|
|
seed: number
|
|
): TrajectoryPoint[] {
|
|
const rng = new SeededRandom(seed)
|
|
const student = new SimulatedStudent(profile, rng)
|
|
|
|
const trajectory: TrajectoryPoint[] = []
|
|
|
|
for (let session = 1; session <= sessionCount; session++) {
|
|
// Simulate ~20 problems per session that exercise this skill
|
|
for (let problem = 0; problem < 20; problem++) {
|
|
// Simulate answering a problem with this skill
|
|
const probability = student.getTrueProbability([skillId])
|
|
const isCorrect = rng.chance(probability)
|
|
|
|
// Increment exposure (learning happens from practice)
|
|
student.incrementExposure(skillId)
|
|
}
|
|
|
|
// Record mastery at end of session
|
|
const mastery = student.getTrueProbability([skillId])
|
|
trajectory.push({ session, mastery })
|
|
}
|
|
|
|
return trajectory
|
|
}
|
|
|
|
function findSessionForMastery(trajectory: TrajectoryPoint[], threshold: number): number | null {
|
|
for (const point of trajectory) {
|
|
if (point.mastery >= threshold) {
|
|
return point.session
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
function getSkillCategory(
|
|
skillId: string
|
|
): 'basic' | 'fiveComplement' | 'tenComplement' | 'advanced' {
|
|
if (skillId.startsWith('basic.')) return 'basic'
|
|
if (skillId.startsWith('fiveComplement')) return 'fiveComplement'
|
|
if (skillId.startsWith('tenComplement')) return 'tenComplement'
|
|
return 'advanced'
|
|
}
|
|
|
|
function getSkillLabel(skillId: string): string {
|
|
const parts = skillId.split('.')
|
|
if (parts.length < 2) return skillId
|
|
const formula = parts[1]
|
|
|
|
if (skillId.startsWith('basic.')) return `basic: ${formula}`
|
|
if (skillId.startsWith('fiveComplements.')) return `5-comp: ${formula}`
|
|
if (skillId.startsWith('fiveComplementsSub.')) return `5-comp sub: ${formula}`
|
|
if (skillId.startsWith('tenComplements.')) return `10-comp: ${formula}`
|
|
if (skillId.startsWith('tenComplementsSub.')) return `10-comp sub: ${formula}`
|
|
if (skillId.startsWith('advanced.')) return `advanced: ${formula}`
|
|
return skillId
|
|
}
|
|
|
|
function getSkillColor(category: string, index: number): string {
|
|
const palettes: Record<string, string[]> = {
|
|
basic: ['#22c55e', '#16a34a', '#15803d', '#166534', '#14532d', '#052e16'],
|
|
fiveComplement: ['#eab308', '#facc15', '#fde047', '#fef08a'],
|
|
tenComplement: [
|
|
'#ef4444',
|
|
'#f97316',
|
|
'#dc2626',
|
|
'#ea580c',
|
|
'#b91c1c',
|
|
'#c2410c',
|
|
'#991b1b',
|
|
'#9a3412',
|
|
'#7f1d1d',
|
|
],
|
|
advanced: ['#8b5cf6', '#a78bfa'],
|
|
}
|
|
const palette = palettes[category] || palettes.basic
|
|
return palette[index % palette.length]
|
|
}
|
|
|
|
async function main() {
|
|
console.log('Generating A/B mastery trajectory data for full curriculum...')
|
|
console.log(`Skills to process: ${ALL_SKILLS.length}`)
|
|
console.log('')
|
|
|
|
const sessionCount = 12
|
|
const seed = 98765
|
|
|
|
// Profile for adaptive mode (BKT targeting)
|
|
const adaptiveProfile: StudentProfile = {
|
|
name: 'Adaptive Learner',
|
|
description: 'Student using adaptive mode',
|
|
halfMaxExposure: 10,
|
|
hillCoefficient: 2.0,
|
|
initialExposures: {}, // Start from zero
|
|
helpUsageProbabilities: [0.7, 0.2, 0.08, 0.02],
|
|
helpBonuses: [0, 0.05, 0.12, 0.25],
|
|
baseResponseTimeMs: 5000,
|
|
responseTimeVariance: 0.3,
|
|
}
|
|
|
|
// Profile for classic mode (no BKT targeting, same learning rate)
|
|
const classicProfile: StudentProfile = {
|
|
...adaptiveProfile,
|
|
name: 'Classic Learner',
|
|
description: 'Student using classic mode',
|
|
}
|
|
|
|
const trajectories: Record<string, SkillTrajectory> = {}
|
|
const startTime = Date.now()
|
|
|
|
for (let i = 0; i < ALL_SKILLS.length; i++) {
|
|
const skillId = ALL_SKILLS[i]
|
|
const skillStart = Date.now()
|
|
|
|
process.stdout.write(`[${i + 1}/${ALL_SKILLS.length}] ${skillId}... `)
|
|
|
|
// Run adaptive simulation
|
|
const adaptiveTrajectory = runSimplifiedJourney(skillId, adaptiveProfile, sessionCount, seed)
|
|
|
|
// Run classic simulation (different seed for variety)
|
|
const classicTrajectory = runSimplifiedJourney(
|
|
skillId,
|
|
classicProfile,
|
|
sessionCount,
|
|
seed + 1000
|
|
)
|
|
|
|
trajectories[skillId] = {
|
|
adaptive: adaptiveTrajectory,
|
|
classic: classicTrajectory,
|
|
sessionsTo50Adaptive: findSessionForMastery(adaptiveTrajectory, 0.5),
|
|
sessionsTo50Classic: findSessionForMastery(classicTrajectory, 0.5),
|
|
sessionsTo80Adaptive: findSessionForMastery(adaptiveTrajectory, 0.8),
|
|
sessionsTo80Classic: findSessionForMastery(classicTrajectory, 0.8),
|
|
}
|
|
|
|
const elapsed = Date.now() - skillStart
|
|
console.log(`done (${elapsed}ms)`)
|
|
}
|
|
|
|
// Compute summary
|
|
let adaptiveWins50 = 0,
|
|
classicWins50 = 0,
|
|
ties50 = 0
|
|
let adaptiveWins80 = 0,
|
|
classicWins80 = 0,
|
|
ties80 = 0
|
|
|
|
for (const skillId of ALL_SKILLS) {
|
|
const t = trajectories[skillId]
|
|
|
|
// 50% comparison
|
|
if (t.sessionsTo50Adaptive !== null && t.sessionsTo50Classic !== null) {
|
|
if (t.sessionsTo50Adaptive < t.sessionsTo50Classic) adaptiveWins50++
|
|
else if (t.sessionsTo50Adaptive > t.sessionsTo50Classic) classicWins50++
|
|
else ties50++
|
|
} else if (t.sessionsTo50Adaptive !== null) {
|
|
adaptiveWins50++
|
|
} else if (t.sessionsTo50Classic !== null) {
|
|
classicWins50++
|
|
} else {
|
|
ties50++
|
|
}
|
|
|
|
// 80% comparison
|
|
if (t.sessionsTo80Adaptive !== null && t.sessionsTo80Classic !== null) {
|
|
if (t.sessionsTo80Adaptive < t.sessionsTo80Classic) adaptiveWins80++
|
|
else if (t.sessionsTo80Adaptive > t.sessionsTo80Classic) classicWins80++
|
|
else ties80++
|
|
} else if (t.sessionsTo80Adaptive !== null) {
|
|
adaptiveWins80++
|
|
} else if (t.sessionsTo80Classic !== null) {
|
|
classicWins80++
|
|
} else {
|
|
ties80++
|
|
}
|
|
}
|
|
|
|
// Build output
|
|
const categoryIndices: Record<string, number> = {}
|
|
|
|
const output = {
|
|
generatedAt: new Date().toISOString(),
|
|
version: '2.0',
|
|
config: { seed, sessionCount, sessionDurationMinutes: 15 },
|
|
summary: {
|
|
totalSkills: ALL_SKILLS.length,
|
|
adaptiveWins50,
|
|
classicWins50,
|
|
ties50,
|
|
adaptiveWins80,
|
|
classicWins80,
|
|
ties80,
|
|
},
|
|
sessions: Array.from({ length: sessionCount }, (_, i) => i + 1),
|
|
skills: ALL_SKILLS.map((skillId) => {
|
|
const category = getSkillCategory(skillId)
|
|
categoryIndices[category] = categoryIndices[category] || 0
|
|
const colorIndex = categoryIndices[category]++
|
|
|
|
const t = trajectories[skillId]
|
|
return {
|
|
id: skillId,
|
|
label: getSkillLabel(skillId),
|
|
category,
|
|
color: getSkillColor(category, colorIndex),
|
|
adaptive: {
|
|
data: t.adaptive.map((p) => Math.round(p.mastery * 100)),
|
|
sessionsTo50: t.sessionsTo50Adaptive,
|
|
sessionsTo80: t.sessionsTo80Adaptive,
|
|
},
|
|
classic: {
|
|
data: t.classic.map((p) => Math.round(p.mastery * 100)),
|
|
sessionsTo50: t.sessionsTo50Classic,
|
|
sessionsTo80: t.sessionsTo80Classic,
|
|
},
|
|
}
|
|
}),
|
|
comparisonTable: ALL_SKILLS.map((skillId) => {
|
|
const t = trajectories[skillId]
|
|
let advantage: string | null = null
|
|
|
|
if (t.sessionsTo80Adaptive !== null && t.sessionsTo80Classic !== null) {
|
|
const diff = t.sessionsTo80Classic - t.sessionsTo80Adaptive
|
|
if (diff > 0) advantage = `Adaptive +${diff} sessions`
|
|
else if (diff < 0) advantage = `Classic +${Math.abs(diff)} sessions`
|
|
else advantage = 'Tie'
|
|
} else if (t.sessionsTo80Adaptive !== null) {
|
|
advantage = 'Adaptive (Classic never reached 80%)'
|
|
} else if (t.sessionsTo80Classic !== null) {
|
|
advantage = 'Classic (Adaptive never reached 80%)'
|
|
}
|
|
|
|
return {
|
|
skill: getSkillLabel(skillId),
|
|
category: getSkillCategory(skillId),
|
|
adaptiveTo80: t.sessionsTo80Adaptive,
|
|
classicTo80: t.sessionsTo80Classic,
|
|
advantage,
|
|
}
|
|
}),
|
|
}
|
|
|
|
// Write output
|
|
const outputDir = path.dirname(OUTPUT_PATH)
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true })
|
|
}
|
|
|
|
fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2))
|
|
|
|
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1)
|
|
console.log('')
|
|
console.log(`=== Complete in ${totalTime}s ===`)
|
|
console.log(`Output: ${OUTPUT_PATH}`)
|
|
console.log('')
|
|
console.log('Summary:')
|
|
console.log(` 50% mastery: Adaptive ${adaptiveWins50}, Classic ${classicWins50}, Ties ${ties50}`)
|
|
console.log(` 80% mastery: Adaptive ${adaptiveWins80}, Classic ${classicWins80}, Ties ${ties80}`)
|
|
}
|
|
|
|
main().catch(console.error)
|