refactor(help): rename helpLevel terminology to hadHelp boolean

The codebase previously used "help level" terminology (HelpLevel type,
helpLevelUsed field, helpLevelWeight function) which implied a graduated
scale. Since the system now only tracks whether help was used or not,
this renames everything to use proper boolean terminology.

Changes:
- Delete HelpLevel type, use boolean directly
- Rename helpLevelUsed → hadHelp in SlotResult
- Rename lastHelpLevel → lastHadHelp in PlayerSkillMastery schema
- Rename helpLevelWeight() → helpWeight() with boolean parameter
- Update all components, tests, stories, and documentation
- Add database migration for column rename

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-12-21 07:04:52 -06:00
parent 446678799c
commit c522620e46
27 changed files with 1174 additions and 92 deletions

View File

@ -68,7 +68,7 @@ export interface SlotResult {
timestamp: number;
responseTimeMs: number;
userAnswer: number | null;
helpLevel: 0 | 1; // Boolean: 0 = no help, 1 = used help
hadHelp: boolean; // Whether student used help during this problem
}
```
@ -223,13 +223,13 @@ export function updateOnIncorrect(
* Adjust observation weight based on whether help was used.
* Using help = less confident the student really knows it.
*
* Note: Help is binary (0 = no help, 1 = used help).
* Note: Help is a boolean (hadHelp: true = used help, false = no help).
* We can't determine which skill needed help for multi-skill problems,
* so we apply the discount uniformly and let conjunctive BKT identify
* weak skills from aggregated evidence.
*/
export function helpLevelWeight(helpLevel: 0 | 1): number {
return helpLevel === 0 ? 1.0 : 0.5; // 50% weight for helped answers
export function helpWeight(hadHelp: boolean): number {
return hadHelp ? 0.5 : 1.0; // 50% weight for helped answers
}
/**
@ -345,7 +345,7 @@ export function getUncertaintyRange(
import type { ProblemResultWithContext } from "../session-planner";
import { getDefaultParams, type BktParams } from "./skill-priors";
import { updateOnCorrect, updateOnIncorrect } from "./conjunctive-bkt";
import { helpLevelWeight, responseTimeWeight } from "./evidence-quality";
import { helpWeight, responseTimeWeight } from "./evidence-quality";
import { calculateConfidence, getUncertaintyRange } from "./confidence";
export interface BktComputeOptions {
@ -428,12 +428,12 @@ export function computeBktFromHistory(
});
// Calculate evidence weight
const helpWeight = helpLevelWeight(result.helpLevel);
const helpW = helpWeight(result.hadHelp);
const rtWeight = responseTimeWeight(
result.responseTimeMs,
result.isCorrect,
);
const evidenceWeight = helpWeight * rtWeight;
const evidenceWeight = helpW * rtWeight;
// Compute updates
const updates = result.isCorrect

View File

@ -0,0 +1,3 @@
-- Custom SQL migration file, put your code below! --
-- Rename last_help_level to last_had_help (terminology change: "help level" is no longer accurate since it's a boolean)
ALTER TABLE `player_skill_mastery` RENAME COLUMN `last_help_level` TO `last_had_help`;

File diff suppressed because it is too large Load Diff

View File

@ -281,6 +281,13 @@
"when": 1766275200000,
"tag": "0039_add_player_archived",
"breakpoints": true
},
{
"idx": 40,
"version": "6",
"when": 1766320890578,
"tag": "0040_rename_last_help_level_to_last_had_help",
"breakpoints": true
}
]
}
}

View File

@ -388,7 +388,7 @@ function InteractiveSessionDemo() {
partNumber: (plan.currentPartIndex + 1) as 1 | 2 | 3,
timestamp: new Date(),
// Default help tracking fields if not provided
helpLevelUsed: result.helpLevelUsed ?? 0,
hadHelp: result.hadHelp ?? false,
incorrectAttempts: result.incorrectAttempts ?? 0,
helpTrigger: result.helpTrigger ?? 'none',
}

View File

@ -899,7 +899,7 @@ export function ActiveSession({
skillsExercised: attemptData.problem.skillsRequired,
usedOnScreenAbacus: phase.phase === 'helpMode',
incorrectAttempts: 0, // TODO: track this properly
helpLevelUsed: phase.phase === 'helpMode' ? 1 : 0,
hadHelp: phase.phase === 'helpMode',
}
await onAnswer(result)

View File

@ -463,14 +463,14 @@ function ProblemDetailPopover({
</span>
</div>
{/* Help level */}
{result && result.helpLevelUsed > 0 && (
{/* Help used */}
{result?.hadHelp && (
<div className={css({ display: 'flex', justifyContent: 'space-between' })}>
<span className={css({ color: isDark ? 'gray.400' : 'gray.600' })}>Help used:</span>
<span
className={css({ fontWeight: 'bold', color: isDark ? 'orange.300' : 'orange.600' })}
>
Level {result.helpLevelUsed}
Yes
</span>
</div>
)}

View File

@ -1136,7 +1136,7 @@ export function DetailedProblemCard({
result.responseTimeMs > autoPauseStats.thresholdMs &&
' (over threshold)'}
</span>
{result.helpLevelUsed > 0 && <span>Help level: {result.helpLevelUsed}</span>}
{result.hadHelp && <span>Used help</span>}
</div>
)}
</div>

View File

@ -90,7 +90,7 @@ function createMockResults(
skillsExercised: ['basic.directAddition'],
usedOnScreenAbacus: partType === 'abacus',
timestamp: new Date(Date.now() - (count - i) * 30000),
helpLevelUsed: 0,
hadHelp: false,
incorrectAttempts: 0,
}))
}
@ -141,7 +141,7 @@ function createSessionHud(config: {
skillsExercised: ['basic.directAddition'],
usedOnScreenAbacus: pIdx === 0,
timestamp: new Date(),
helpLevelUsed: 0,
hadHelp: false,
incorrectAttempts: 0,
})
}

View File

@ -420,13 +420,13 @@ export function ProblemToReview({
🧮 Used on-screen abacus
</span>
)}
{result.helpLevelUsed > 0 && (
{result.hadHelp && (
<span
className={css({
color: isDark ? 'orange.400' : 'orange.600',
})}
>
💡 Help level: {result.helpLevelUsed}
💡 Used help
</span>
)}
</div>

View File

@ -18,8 +18,8 @@ export interface SkillProgress {
attempts: number
correct: number
consecutiveCorrect: number
/** Last help level used on this skill (0 or 1) */
lastHelpLevel?: number
/** Whether help was used when this skill was last practiced */
lastHadHelp?: boolean
}
/**

View File

@ -103,7 +103,7 @@ function createMockSessionPlan(config: {
skillsExercised: ['basic.directAddition'],
usedOnScreenAbacus: i < 5,
timestamp: new Date(Date.now() - (completedCount - i) * 30000),
helpLevelUsed: 0,
hadHelp: false,
incorrectAttempts: 0,
}))

View File

@ -104,8 +104,8 @@ export function filterProblemsNeedingAttention(
reasons.push('slow')
}
// Check if used help (helpLevelUsed is binary: 0 = no help, 1 = help used)
if (problem.result.helpLevelUsed >= 1) {
// Check if used help
if (problem.result.hadHelp) {
reasons.push('help-used')
}

View File

@ -57,9 +57,9 @@ export const playerSkillMastery = sqliteTable(
.$defaultFn(() => new Date()),
/**
* Last help level used on this skill (0 = no help, 1 = used help)
* Whether help was used the last time this skill was practiced
*/
lastHelpLevel: integer('last_help_level').notNull().default(0),
lastHadHelp: integer('last_had_help', { mode: 'boolean' }).notNull().default(false),
},
(table) => ({
/** Index for fast lookups by playerId */

View File

@ -213,16 +213,6 @@ export interface SessionAdjustment {
previousHealth: SessionHealth
}
/**
* Help level used during a problem (boolean)
* - 0: No help requested
* - 1: Help was used (interactive abacus overlay shown)
*
* Note: The system previously defined levels 0-3, but only 0/1 are ever recorded.
* BKT uses conjunctive blame attribution to identify weak skills.
*/
export type HelpLevel = 0 | 1
/**
* Result of a single problem slot
*/
@ -241,8 +231,8 @@ export interface SlotResult {
// ---- Help Tracking (for feedback loop) ----
/** Maximum help level used during this problem (0 = no help) */
helpLevelUsed: HelpLevel
/** Whether the student used help during this problem */
hadHelp: boolean
/** Number of incorrect attempts before getting the right answer */
incorrectAttempts: number

View File

@ -10,7 +10,7 @@ import { BKT_THRESHOLDS } from '../config/bkt-integration'
import type { ProblemResultWithContext } from '../session-planner'
import { calculateConfidence, getUncertaintyRange } from './confidence'
import { type BlameMethod, updateOnCorrect, updateOnIncorrectWithMethod } from './conjunctive-bkt'
import { helpLevelWeight, responseTimeWeight } from './evidence-quality'
import { helpWeight, responseTimeWeight } from './evidence-quality'
import { getDefaultParams } from './skill-priors'
import type {
BktComputeOptions,
@ -125,10 +125,10 @@ export function computeBktFromHistory(
}
})
// Calculate evidence weight based on help level and response time
const helpWeight = helpLevelWeight(result.helpLevelUsed)
// Calculate evidence weight based on help usage and response time
const helpW = helpWeight(result.hadHelp)
const rtWeight = responseTimeWeight(result.responseTimeMs, result.isCorrect)
const evidenceWeight = helpWeight * rtWeight
const evidenceWeight = helpW * rtWeight
// Compute BKT updates (conjunctive model)
const blameMethod = opts.blameMethod ?? 'heuristic'

View File

@ -3,26 +3,19 @@
*
* Not all observations are equally informative. We adjust the weight
* of evidence based on:
* - Help level: Using help = less confident the student really knows it
* - Help usage: Using help = less confident the student really knows it
* - Response time: Fast correct = strong mastery, slow correct = struggled
*/
import type { HelpLevel } from '@/db/schema/session-plans'
/**
* Adjust observation weight based on whether help was used.
* Using help = less confident the student really knows it.
*
* @param helpLevel - 0 = no help, 1 = help used
* @param hadHelp - true if help was used, false otherwise
* @returns Weight multiplier [0.5, 1.0]
*/
export function helpLevelWeight(helpLevel: HelpLevel): number {
// Guard against unexpected values (legacy data, JSON parsing issues)
if (helpLevel !== 0 && helpLevel !== 1) {
return 1.0
}
// 0 = no help (full evidence), 1 = used help (50% evidence)
return helpLevel === 0 ? 1.0 : 0.5
export function helpWeight(hadHelp: boolean): number {
return hadHelp ? 0.5 : 1.0
}
/**
@ -67,13 +60,13 @@ export function responseTimeWeight(
}
/**
* Combined evidence weight from help and response time.
* Combined evidence weight from help usage and response time.
*/
export function combinedEvidenceWeight(
helpLevel: HelpLevel,
hadHelp: boolean,
responseTimeMs: number,
isCorrect: boolean,
expectedTimeMs: number = 5000
): number {
return helpLevelWeight(helpLevel) * responseTimeWeight(responseTimeMs, isCorrect, expectedTimeMs)
return helpWeight(hadHelp) * responseTimeWeight(responseTimeMs, isCorrect, expectedTimeMs)
}

View File

@ -58,7 +58,7 @@ export { getDefaultParams, getSkillCategory } from './skill-priors'
// Evidence quality (for advanced use cases)
export {
combinedEvidenceWeight,
helpLevelWeight,
helpWeight,
responseTimeWeight,
} from './evidence-quality'

View File

@ -8,7 +8,6 @@ import { db, schema } from '@/db'
import type { NewPlayerCurriculum, PlayerCurriculum } from '@/db/schema/player-curriculum'
import type { NewPlayerSkillMastery, PlayerSkillMastery } from '@/db/schema/player-skill-mastery'
import type { PracticeSession } from '@/db/schema/practice-sessions'
import type { HelpLevel } from '@/db/schema/session-plans'
import {
isTutorialSatisfied,
type NewSkillTutorialProgress,
@ -261,20 +260,18 @@ export async function recordSkillAttempt(
}
/**
* Record a skill attempt with help level tracking
* Record a skill attempt with help tracking
*
* Updates the lastPracticedAt timestamp and tracks whether help was used.
* BKT handles mastery estimation via evidence weighting (helped answers get 0.5x weight).
*
* NOTE: The old reinforcement system (based on help levels 2+) has been removed.
* Only boolean help (0 or 1) is recorded. BKT's conjunctive blame attribution
* identifies weak skills from multi-skill problems.
* NOTE: BKT's conjunctive blame attribution identifies weak skills from multi-skill problems.
*/
export async function recordSkillAttemptWithHelp(
playerId: string,
skillId: string,
_isCorrect: boolean,
helpLevel: HelpLevel,
hadHelp: boolean,
_responseTimeMs?: number
): Promise<PlayerSkillMastery> {
const existing = await getSkillMastery(playerId, skillId)
@ -286,7 +283,7 @@ export async function recordSkillAttemptWithHelp(
.set({
lastPracticedAt: now,
updatedAt: now,
lastHelpLevel: helpLevel,
lastHadHelp: hadHelp,
})
.where(eq(schema.playerSkillMastery.id, existing.id))
@ -299,7 +296,7 @@ export async function recordSkillAttemptWithHelp(
skillId,
isPracticing: true, // skill is being practiced
lastPracticedAt: now,
lastHelpLevel: helpLevel,
lastHadHelp: hadHelp,
}
await db.insert(schema.playerSkillMastery).values(newRecord)
@ -314,7 +311,7 @@ export async function recordSkillAttemptWithHelp(
export async function recordSkillAttemptsWithHelp(
playerId: string,
skillResults: Array<{ skillId: string; isCorrect: boolean }>,
helpLevel: HelpLevel,
hadHelp: boolean,
responseTimeMs?: number
): Promise<PlayerSkillMastery[]> {
const results: PlayerSkillMastery[] = []
@ -324,7 +321,7 @@ export async function recordSkillAttemptsWithHelp(
playerId,
skillId,
isCorrect,
helpLevel,
hadHelp,
responseTimeMs
)
results.push(result)

View File

@ -805,7 +805,7 @@ export async function recordSlotResult(
await recordSkillAttemptsWithHelp(
plan.playerId,
skillResults,
result.helpLevelUsed,
result.hadHelp,
result.responseTimeMs
)
} catch (skillError) {

View File

@ -169,7 +169,7 @@ function createResultFromProblem(
skillsExercised: problem.skillsUsed,
usedOnScreenAbacus: false,
timestamp,
helpLevelUsed: 0,
hadHelp: false,
incorrectAttempts: 0,
sessionCompletedAt: timestamp,
partType: 'abacus',

View File

@ -49,7 +49,7 @@ function createResult(
skillsExercised,
usedOnScreenAbacus: false,
timestamp,
helpLevelUsed: 0,
hadHelp: false,
incorrectAttempts: 0,
sessionCompletedAt: timestamp,
partType: 'abacus',
@ -174,7 +174,7 @@ function generateSyntheticResults(
isCorrect,
responseTimeMs: containsWeakSkill ? 8000 : 3000, // Slower on weak skill
usedOnScreenAbacus: false,
helpLevelUsed: 0,
hadHelp: false,
incorrectAttempts: 0,
sessionCompletedAt: new Date(baseTime + i * 5000),
partType: 'abacus',

View File

@ -142,9 +142,7 @@ export async function initializeSkillMastery(
playerId,
skillId,
isPracticing,
needsReinforcement: false,
lastHelpLevel: 0,
reinforcementStreak: 0,
lastHadHelp: false,
createdAt: now,
lastPracticedAt: null,
})

View File

@ -158,9 +158,9 @@ export class JourneyRunner {
responseTimeMs: answer.responseTimeMs,
skillsExercised: answer.skillsChallenged,
usedOnScreenAbacus: false,
helpLevelUsed: answer.helpLevelUsed,
hadHelp: answer.hadHelp,
incorrectAttempts: answer.isCorrect ? 0 : 1,
helpTrigger: answer.helpLevelUsed > 0 ? 'manual' : 'none',
helpTrigger: answer.hadHelp ? 'manual' : 'none',
})
}
}

View File

@ -20,7 +20,7 @@
* - Help provides additive bonus to probability
*/
import type { GeneratedProblem, HelpLevel } from '@/db/schema/session-plans'
import type { GeneratedProblem } from '@/db/schema/session-plans'
import type { SeededRandom } from './SeededRandom'
import type { SimulatedAnswer, StudentProfile } from './types'
@ -175,20 +175,20 @@ export class SimulatedStudent {
}
// Determine if student uses help (binary)
const helpLevelUsed = this.selectHelpLevel()
const hadHelp = this.selectHelpUsage()
// Calculate answer probability using Hill function + conjunctive model
const answerProbability = this.calculateAnswerProbability(skillsChallenged, helpLevelUsed)
const answerProbability = this.calculateAnswerProbability(skillsChallenged, hadHelp)
const isCorrect = this.rng.chance(answerProbability)
// Calculate response time
const responseTimeMs = this.calculateResponseTime(skillsChallenged, helpLevelUsed, isCorrect)
const responseTimeMs = this.calculateResponseTime(skillsChallenged, hadHelp, isCorrect)
return {
isCorrect,
responseTimeMs,
helpLevelUsed,
hadHelp,
skillsChallenged,
fatigue,
}
@ -202,7 +202,7 @@ export class SimulatedStudent {
*
* Help bonus is additive (applied after the product).
*/
private calculateAnswerProbability(skillIds: string[], helpLevel: HelpLevel): number {
private calculateAnswerProbability(skillIds: string[], hadHelp: boolean): number {
if (skillIds.length === 0) {
// Basic problems (no special skills) almost always correct
return 0.95
@ -220,7 +220,8 @@ export class SimulatedStudent {
}
// Add help bonus (additive, not multiplicative)
const helpBonus = this.profile.helpBonuses[helpLevel]
// helpBonuses[0] = no help, helpBonuses[1] = with help
const helpBonus = this.profile.helpBonuses[hadHelp ? 1 : 0]
probability += helpBonus
// Clamp to valid probability range
@ -229,12 +230,12 @@ export class SimulatedStudent {
}
/**
* Select whether student uses help (binary).
* Select whether student uses help.
* Based on profile's helpUsageProbabilities [P(no help), P(help)].
*/
private selectHelpLevel(): HelpLevel {
private selectHelpUsage(): boolean {
const [pNoHelp] = this.profile.helpUsageProbabilities
return this.rng.next() < pNoHelp ? 0 : 1
return this.rng.next() >= pNoHelp
}
/**
@ -242,7 +243,7 @@ export class SimulatedStudent {
*/
private calculateResponseTime(
skillIds: string[],
helpLevel: HelpLevel,
hadHelp: boolean,
isCorrect: boolean
): number {
const base = this.profile.baseResponseTimeMs
@ -258,7 +259,7 @@ export class SimulatedStudent {
const exposureFactor = 2.0 - Math.min(1.0, avgExposure / (this.profile.halfMaxExposure * 2))
// Help usage adds time (reading hints, etc.)
const helpFactor = 1.0 + helpLevel * 0.25
const helpFactor = hadHelp ? 1.25 : 1.0
// Incorrect answers: sometimes faster (gave up), sometimes slower (struggled)
const correctnessFactor = isCorrect ? 1.0 : this.rng.chance(0.5) ? 0.7 : 1.4

View File

@ -4,7 +4,6 @@
* Type definitions for the BKT validation test infrastructure.
*/
import type { HelpLevel } from '@/db/schema/session-plans'
import type { BlameMethod } from '@/lib/curriculum/bkt'
import type { ProblemGenerationMode } from '@/lib/curriculum/config/bkt-integration'
@ -114,8 +113,8 @@ export interface SimulatedAnswer {
isCorrect: boolean
/** Time taken to answer in milliseconds */
responseTimeMs: number
/** Help level used (0 = no help, 1 = used help) */
helpLevelUsed: HelpLevel
/** Whether help was used during this problem */
hadHelp: boolean
/** Skills that were actually challenged by this problem */
skillsChallenged: string[]
/**

View File

@ -104,7 +104,7 @@ describe('Session Targeting Trace', () => {
await recordSlotResult(plan1.id, part.partNumber, slot.index, {
isCorrect: answer.isCorrect,
responseTimeMs: answer.responseTimeMs,
helpLevelUsed: answer.helpLevelUsed,
hadHelp: answer.hadHelp,
skillsExercised: answer.skillsChallenged,
})
}
@ -189,7 +189,7 @@ describe('Session Targeting Trace', () => {
await recordSlotResult(classicPlan1.id, part.partNumber, slot.index, {
isCorrect: answer.isCorrect,
responseTimeMs: answer.responseTimeMs,
helpLevelUsed: answer.helpLevelUsed,
hadHelp: answer.hadHelp,
skillsExercised: answer.skillsChallenged,
})
}