Phase 6: take a test (weighted sampling + question flow)
- internal/sampling: ComputeWeight (Laplace-smoothed error rate + recency
multiplier, floor 0.15) and SelectWeighted (A-Res reservoir algorithm).
10k-run statistical test verifies weak questions appear >3x more often
than mastered, and mastered questions still appear (floor exercised).
- GET/POST /test/new: source filter with live available-count JS update,
n-questions input, weighted vs uniform mode radio.
- GET /test/{id}/q/{n}: deterministic answer shuffle per (test_id,
question_id), progress bar, mobile-friendly large tap targets.
- POST /test/{id}/q/{n}: records answer + upserts stat; advances to next
question or finishes test and redirects to results stub.
- GET /test/{id}/results: stub (Phase 7 will add full review).
- Ownership enforced: all test routes 404 for wrong user.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
package sampling
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"qbank/internal/models"
|
||||
)
|
||||
|
||||
const (
|
||||
FloorWeight = 0.15 // mastered questions still appear at ~15% base rate
|
||||
RecencyCapDays = 30.0 // days until recency multiplier saturates
|
||||
RecencyMaxMult = 2.0 // peak recency multiplier
|
||||
UnseenBaseWeight = 0.5 // base weight for questions with no stats row
|
||||
)
|
||||
|
||||
// ComputeWeight returns the sampling weight for a question given its per-user
|
||||
// stat. A nil stat means the question has never been seen.
|
||||
func ComputeWeight(stat *models.UserQuestionStat, now time.Time) float64 {
|
||||
if stat == nil {
|
||||
// Unseen: mid-range base + full recency = 1.0
|
||||
return UnseenBaseWeight * RecencyMaxMult
|
||||
}
|
||||
|
||||
s := float64(stat.TimesSeen)
|
||||
c := float64(stat.TimesCorrect)
|
||||
|
||||
// Laplace-smoothed error rate dampens noise from small samples.
|
||||
errorRate := (s - c + 1) / (s + 2)
|
||||
base := math.Max(FloorWeight, errorRate)
|
||||
|
||||
var daysSince float64
|
||||
if stat.LastSeenAt.Valid {
|
||||
daysSince = now.Sub(stat.LastSeenAt.Time).Hours() / 24
|
||||
} else {
|
||||
daysSince = RecencyCapDays
|
||||
}
|
||||
recency := 1 + math.Min(daysSince/RecencyCapDays, 1.0)
|
||||
|
||||
return base * recency
|
||||
}
|
||||
Reference in New Issue
Block a user