fix(bkt): handle missing helpLevelUsed in legacy data causing NaN

Root cause: Old production data (before Dec 2024) is missing the
`helpLevelUsed` field. The `helpLevelWeight` function had no default
case in its switch statement, returning `undefined` when called with
undefined, which caused `undefined * rtWeight = NaN` to propagate
through BKT calculations.

Changes:
- evidence-quality.ts: Add default case returning 1.0 for undefined/null
  and add NaN guard to responseTimeWeight
- bkt-core.ts: Add NaN guards that surface invalid data with console.warn
- conjunctive-bkt.ts: Add NaN guards for multi-skill BKT updates
- compute-bkt.ts: Skip problem results that would produce NaN, preserving
  prior state rather than corrupting skill estimates
- bkt-integration.ts: Add NaN guard to calculateBktMultiplier with
  conservative fallback
- DashboardClient.tsx: Add UI error state for NaN pKnown values showing
  "⚠️ Data Error" instead of displaying "~NaN%"

🤖 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-20 14:31:47 -06:00
parent f760ec130e
commit b300ed9f5c
6 changed files with 236 additions and 69 deletions

View File

@@ -509,34 +509,48 @@ function SkillCard({
marginBottom: '0.25rem',
})}
>
<span
className={css({
fontWeight: 'bold',
color:
skill.pKnown >= 0.8
? isDark
? 'green.400'
: 'green.600'
: skill.pKnown < 0.5
? isDark
? 'red.400'
: 'red.600'
: isDark
? 'yellow.400'
: 'yellow.600',
})}
>
~{Math.round(skill.pKnown * 100)}%
</span>
{confidenceLabel && (
{!Number.isFinite(skill.pKnown) ? (
<span
className={css({
color: isDark ? 'gray.500' : 'gray.500',
fontSize: '0.625rem',
fontWeight: 'bold',
color: isDark ? 'orange.400' : 'orange.600',
})}
title="BKT calculation error - check browser console for details"
>
({confidenceLabel})
Data Error
</span>
) : (
<>
<span
className={css({
fontWeight: 'bold',
color:
skill.pKnown >= 0.8
? isDark
? 'green.400'
: 'green.600'
: skill.pKnown < 0.5
? isDark
? 'red.400'
: 'red.600'
: isDark
? 'yellow.400'
: 'yellow.600',
})}
>
~{Math.round(skill.pKnown * 100)}%
</span>
{confidenceLabel && (
<span
className={css({
color: isDark ? 'gray.500' : 'gray.500',
fontSize: '0.625rem',
})}
>
({confidenceLabel})
</span>
)}
</>
)}
</div>
)}
@@ -777,8 +791,11 @@ function SkillDetailDrawer({
padding: '1rem',
borderBottom: '1px solid',
borderColor: isDark ? 'gray.700' : 'gray.200',
backgroundColor:
skill.pKnown >= 0.8
backgroundColor: !Number.isFinite(skill.pKnown)
? isDark
? 'orange.900/20'
: 'orange.50'
: skill.pKnown >= 0.8
? isDark
? 'green.900/20'
: 'green.50'
@@ -800,55 +817,85 @@ function SkillDetailDrawer({
>
Estimated Mastery
</div>
<div
className={css({
display: 'flex',
alignItems: 'baseline',
gap: '0.5rem',
})}
>
<span
{!Number.isFinite(skill.pKnown) ? (
<div
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color:
skill.pKnown >= 0.8
? isDark
? 'green.400'
: 'green.600'
: skill.pKnown < 0.5
? isDark
? 'red.400'
: 'red.600'
: isDark
? 'yellow.400'
: 'yellow.600',
display: 'flex',
flexDirection: 'column',
gap: '0.5rem',
})}
>
~{Math.round(skill.pKnown * 100)}%
</span>
{skill.confidence !== null && (
<span
className={css({
fontSize: '0.875rem',
fontSize: '1.5rem',
fontWeight: 'bold',
color: isDark ? 'orange.400' : 'orange.600',
})}
>
Data Error
</span>
<span
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
({getConfidenceLabel(skill.confidence)} confidence)
BKT calculation failed. Check browser console for details.
</span>
)}
</div>
{skill.uncertaintyRange && (
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginTop: '0.25rem',
})}
>
Range: {Math.round(skill.uncertaintyRange.low * 100)}% -{' '}
{Math.round(skill.uncertaintyRange.high * 100)}%
</div>
) : (
<>
<div
className={css({
display: 'flex',
alignItems: 'baseline',
gap: '0.5rem',
})}
>
<span
className={css({
fontSize: '2rem',
fontWeight: 'bold',
color:
skill.pKnown >= 0.8
? isDark
? 'green.400'
: 'green.600'
: skill.pKnown < 0.5
? isDark
? 'red.400'
: 'red.600'
: isDark
? 'yellow.400'
: 'yellow.600',
})}
>
~{Math.round(skill.pKnown * 100)}%
</span>
{skill.confidence !== null && (
<span
className={css({
fontSize: '0.875rem',
color: isDark ? 'gray.400' : 'gray.600',
})}
>
({getConfidenceLabel(skill.confidence)} confidence)
</span>
)}
</div>
{skill.uncertaintyRange && Number.isFinite(skill.uncertaintyRange.low) && (
<div
className={css({
fontSize: '0.75rem',
color: isDark ? 'gray.500' : 'gray.500',
marginTop: '0.25rem',
})}
>
Range: {Math.round(skill.uncertaintyRange.low * 100)}% -{' '}
{Math.round(skill.uncertaintyRange.high * 100)}%
</div>
)}
</>
)}
</div>
)}

View File

@@ -37,6 +37,16 @@ import type { BktParams } from './types'
export function bktUpdate(priorPKnown: number, isCorrect: boolean, params: BktParams): number {
const { pSlip, pGuess } = params
// Surface data issues - log warning and let NaN propagate so UI can show error state
if (!Number.isFinite(priorPKnown)) {
console.warn('[BKT] Invalid priorPKnown detected:', priorPKnown, '- letting NaN propagate')
return Number.NaN // Let NaN propagate for UI error boundaries
}
if (!Number.isFinite(pSlip) || !Number.isFinite(pGuess)) {
console.warn('[BKT] Invalid params detected:', { pSlip, pGuess }, '- letting NaN propagate')
return Number.NaN // Let NaN propagate for UI error boundaries
}
// Guard against division by zero
const safeSlip = Math.max(0.001, Math.min(0.999, pSlip))
const safeGuess = Math.max(0.001, Math.min(0.999, pGuess))
@@ -71,5 +81,15 @@ export function bktUpdate(priorPKnown: number, isCorrect: boolean, params: BktPa
* @returns P(known) after learning transition
*/
export function applyLearning(pKnown: number, pLearn: number): number {
// Surface data issues - log warning and let NaN propagate
if (!Number.isFinite(pKnown)) {
console.warn('[BKT] applyLearning: Invalid pKnown:', pKnown, '- letting NaN propagate')
return Number.NaN
}
if (!Number.isFinite(pLearn)) {
console.warn('[BKT] applyLearning: Invalid pLearn:', pLearn, '- letting NaN propagate')
return Number.NaN
}
return pKnown + (1 - pKnown) * pLearn
}

View File

@@ -136,6 +136,20 @@ export function computeBktFromHistory(
? updateOnCorrect(skillRecords)
: updateOnIncorrectWithMethod(skillRecords, blameMethod)
// Check if any update produced NaN (indicates invalid input data)
const hasInvalidUpdate = updates.some((u) => !Number.isFinite(u.updatedPKnown))
if (hasInvalidUpdate) {
console.warn(
'[BKT] Skipping problem result due to invalid data - skills:',
skillIds.join(', '),
'timestamp:',
result.timestamp,
'Updates with NaN:',
updates.filter((u) => !Number.isFinite(u.updatedPKnown)).map((u) => u.skillId)
)
continue // Skip this problem, don't corrupt skill states
}
// Apply updates with evidence weighting
for (const update of updates) {
const state = skillStates.get(update.skillId)!
@@ -145,6 +159,20 @@ export function computeBktFromHistory(
// When evidenceWeight < 1.0, stay closer to prior
const newPKnown = state.pKnown * (1 - evidenceWeight) + update.updatedPKnown * evidenceWeight
// Final safety check - if blending still produces NaN, preserve prior state
if (!Number.isFinite(newPKnown)) {
console.warn(
'[BKT] Evidence blending produced NaN for skill:',
update.skillId,
'evidenceWeight:',
evidenceWeight,
'updatedPKnown:',
update.updatedPKnown,
'- preserving prior state'
)
continue
}
state.pKnown = newPKnown
state.opportunities += 1
if (result.isCorrect) state.successCount += 1
@@ -179,6 +207,16 @@ export function computeBktFromHistory(
// Classify mastery based on P(known) and confidence
const masteryClassification = classifyMastery(finalPKnown, confidence, opts.confidenceThreshold)
// Final safety check - this should not happen after skipping invalid updates
// If it does, warn and let UI show error state for this specific skill
if (!Number.isFinite(finalPKnown)) {
console.warn(
`[BKT] UNEXPECTED: Skill ${skillId} has corrupted pKnown after processing: ${finalPKnown}. ` +
`Opportunities: ${state.opportunities}, SuccessCount: ${state.successCount}. ` +
'This should not happen - invalid updates should have been skipped.'
)
}
skills.push({
skillId,
pKnown: finalPKnown,

View File

@@ -28,10 +28,33 @@ export type BlameMethod = 'heuristic' | 'bayesian'
export function updateOnCorrect(
skills: SkillBktRecord[]
): { skillId: string; updatedPKnown: number }[] {
return skills.map((skill) => ({
skillId: skill.skillId,
updatedPKnown: applyLearning(bktUpdate(skill.pKnown, true, skill.params), skill.params.pLearn),
}))
return skills.map((skill) => {
// Surface data issues - log warning and let NaN propagate for UI error boundaries
if (!Number.isFinite(skill.pKnown)) {
console.warn(
'[BKT] updateOnCorrect: Invalid pKnown for skill:',
skill.skillId,
skill.pKnown,
'- letting NaN propagate'
)
return {
skillId: skill.skillId,
updatedPKnown: Number.NaN, // Let NaN propagate for UI error state
}
}
const updated = applyLearning(bktUpdate(skill.pKnown, true, skill.params), skill.params.pLearn)
if (!Number.isFinite(updated)) {
console.warn(
'[BKT] updateOnCorrect: Calculation produced NaN for skill:',
skill.skillId,
'- letting NaN propagate'
)
}
return {
skillId: skill.skillId,
updatedPKnown: updated, // Let NaN propagate if it occurred
}
})
}
/**
@@ -49,6 +72,21 @@ export function updateOnCorrect(
* the error receive less negative evidence.
*/
export function updateOnIncorrect(skills: SkillBktRecord[]): BlameDistribution[] {
// Surface data issues - log warning for any skills with invalid pKnown
const invalidSkills = skills.filter((s) => !Number.isFinite(s.pKnown))
if (invalidSkills.length > 0) {
console.warn(
'[BKT] updateOnIncorrect: Found skills with invalid pKnown - letting NaN propagate:',
invalidSkills.map((s) => ({ id: s.skillId, pKnown: s.pKnown }))
)
// Return NaN for all skills so UI shows error state for the whole calculation
return skills.map((skill) => ({
skillId: skill.skillId,
blameWeight: Number.NaN,
updatedPKnown: Number.NaN,
}))
}
// Calculate total "unknown-ness" across all skills
const totalUnknown = skills.reduce((sum, s) => sum + (1 - s.pKnown), 0)
@@ -78,7 +116,7 @@ export function updateOnIncorrect(skills: SkillBktRecord[]): BlameDistribution[]
return {
skillId: skill.skillId,
blameWeight,
updatedPKnown: weightedPKnown,
updatedPKnown: weightedPKnown, // Let NaN propagate if it occurred
}
})
}

View File

@@ -24,6 +24,9 @@ export function helpLevelWeight(helpLevel: 0 | 1 | 2 | 3): number {
return 0.5 // Significant help - halve evidence
case 3:
return 0.5 // Full help - halve evidence
default:
// Guard against unexpected values (e.g., null, undefined, or invalid numbers from JSON parsing)
return 1.0
}
}
@@ -46,6 +49,15 @@ export function responseTimeWeight(
isCorrect: boolean,
expectedTimeMs: number = 5000
): number {
// Guard against invalid values that would produce NaN
if (
typeof responseTimeMs !== 'number' ||
!Number.isFinite(responseTimeMs) ||
responseTimeMs <= 0
) {
return 1.0 // Neutral weight for invalid data
}
const ratio = responseTimeMs / expectedTimeMs
if (isCorrect) {

View File

@@ -193,6 +193,18 @@ export const BKT_INTEGRATION_CONFIG = {
export function calculateBktMultiplier(pKnown: number): number {
const { minMultiplier, maxMultiplier } = BKT_INTEGRATION_CONFIG
// Guard against NaN/invalid pKnown - this is a "consumer" of BKT data, not a producer
// We log the warning (surfacing the issue) and return maxMultiplier (conservative fallback)
// This allows problem generation to continue while indicating an unknown mastery level
if (!Number.isFinite(pKnown)) {
console.warn(
'[BKT] calculateBktMultiplier: Invalid pKnown:',
pKnown,
'- using maxMultiplier as fallback'
)
return maxMultiplier // Conservative: harder problems, allows system to continue
}
// Non-linear (square) interpolation: pKnown²=0 → max, pKnown²=1 → min
// This spreads out the high P(known) range for better differentiation
const effectivePKnown = pKnown * pKnown