/** * Per-grade-tier accuracy tracking. * * Every resolution increments counters for (sport, grade, period). * The "all_time" period is the canonical record; "last_30d" and * "last_7d" are derived views recomputed by a periodic refresh job * (n8n). We update all three counters on every resolve so callers can * read instant values without rolling a window themselves. * * BASELINE LOCK: * After a (sport, grade, 'all_time') accumulates 100 decisive * resolutions (hits + misses, not push/void), the hit rate at that * moment is locked as `baseline_hit_rate` and `baseline_locked` * flips to true. Future accuracy compares to the baseline to detect * drift. * * EXPECTED HIT RATES (from spec): * A+ ≥ 65%, A ≥ 60%, A- ≥ 58%, B+ ≥ 55%, B ≥ 53%, B- ≥ 51% * C+ ≈ 50%, C ≈ 48%, C- ≈ 45%, D ≈ 40%, F ≈ 35% */ const { getSupabaseServiceClient } = require('../../utils/supabase'); const BASELINE_LOCK_AT = 100; const PERIODS = ['all_time', 'last_30d', 'last_7d']; const EXPECTED_HIT_RATES = Object.freeze({ 'A+': 0.65, 'A': 0.60, 'A-': 0.58, 'B+': 0.55, 'B': 0.53, 'B-': 0.51, 'C+': 0.50, 'C': 0.48, 'C-': 0.45, 'D': 0.40, 'F': 0.35, }); function computeHitRate(hit, miss) { const denom = hit + miss; return denom > 0 ? hit / denom : null; } async function fetchRow(supabase, sport, grade, period) { const { data, error } = await supabase .from('accuracy_tracking') .select('*') .eq('sport', sport) .eq('grade', grade) .eq('period', period) .maybeSingle(); if (error) { console.warn('[accuracy] fetch failed:', error.message); return null; } return data; } async function upsertRow(supabase, row) { const { error } = await supabase .from('accuracy_tracking') .upsert(row, { onConflict: 'sport,grade,period' }); if (error) console.warn('[accuracy] upsert failed:', error.message); } async function recordResolution(sport, grade, result) { if (!sport || !grade || !result) return; const supabase = getSupabaseServiceClient(); for (const period of PERIODS) { const existing = await fetchRow(supabase, sport, grade, period) || { sport, grade, period, total_graded: 0, total_hit: 0, total_miss: 0, total_push: 0, total_void: 0, hit_rate: null, baseline_hit_rate: null, baseline_locked: false, }; existing.total_graded += 1; if (result === 'hit') existing.total_hit += 1; else if (result === 'miss') existing.total_miss += 1; else if (result === 'push') existing.total_push += 1; else if (result === 'void') existing.total_void += 1; existing.hit_rate = computeHitRate(existing.total_hit, existing.total_miss); if ( period === 'all_time' && !existing.baseline_locked && (existing.total_hit + existing.total_miss) >= BASELINE_LOCK_AT ) { existing.baseline_hit_rate = existing.hit_rate; existing.baseline_locked = true; } existing.last_updated = new Date().toISOString(); await upsertRow(supabase, existing); } } async function getAccuracy(sport, grade, period = 'all_time') { const supabase = getSupabaseServiceClient(); const row = await fetchRow(supabase, sport, grade, period); if (!row) { return { sport, grade, period, hit_rate: null, baseline: null, expected: EXPECTED_HIT_RATES[grade] ?? null, total: 0, delta: null, locked: false, }; } const total = row.total_hit + row.total_miss; const delta = row.baseline_hit_rate != null && row.hit_rate != null ? row.hit_rate - row.baseline_hit_rate : null; return { sport, grade, period, hit_rate: row.hit_rate, baseline: row.baseline_hit_rate, expected: EXPECTED_HIT_RATES[grade] ?? null, total, delta, locked: !!row.baseline_locked, }; } async function getAllAccuracy(sport, period = 'all_time') { const grades = Object.keys(EXPECTED_HIT_RATES); const out = []; for (const grade of grades) out.push(await getAccuracy(sport, grade, period)); return out; } async function isBaselineLocked(sport, grade) { const supabase = getSupabaseServiceClient(); const row = await fetchRow(supabase, sport, grade, 'all_time'); return !!row?.baseline_locked; } async function getAccuracyDashboard() { const supabase = getSupabaseServiceClient(); const { data, error } = await supabase .from('accuracy_tracking') .select('*') .eq('period', 'all_time'); if (error) { console.warn('[accuracy] dashboard query failed:', error.message); return []; } return data || []; } module.exports = { recordResolution, getAccuracy, getAllAccuracy, isBaselineLocked, getAccuracyDashboard, EXPECTED_HIT_RATES, BASELINE_LOCK_AT, __internals: { computeHitRate, PERIODS }, };