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:
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user