test / multiq /difficulty.py
yousseftallal's picture
Quality: chat-template prompting, robust JSON parse, grounded context fallback, real difficulty distance
e0ddbe9
Raw History Blame Contribute Delete
2.19 kB
"""
Difficulty scorer: combines
- semantic distance between claim and context (hard if very distant)
- answer length entropy (hard if longer numerical / multi-step)
- distractor similarity (hard if distractors almost correct)
- curriculum mapping (maps grade level β†’ easy / medium / hard)
"""
from __future__ import annotations
import re
import numpy as np
from .config import Settings
class DifficultyScorer:
def __init__(self, settings: Settings, dense_encoder):
self.s = settings
self.dense = dense_encoder
def score(
self,
question: str,
answer: str,
distractors: list[str],
context: str,
) -> str:
ctx_emb = self.dense.encode([context], normalize_embeddings=True)
ans_emb = self.dense.encode([answer], normalize_embeddings=True)
q_emb = self.dense.encode([question], normalize_embeddings=True)
# semantic distance between question and context (hard if very distant)
dist_q_ctx = float(np.dot(q_emb[0], ctx_emb[0]))
distractor_scores = []
for d in distractors:
d_emb = self.dense.encode([d], normalize_embeddings=True)
distractor_scores.append(float(np.dot(ans_emb[0], d_emb[0])))
avg_distractor_sim = float(np.mean(distractor_scores)) if distractor_scores else 0.0
answer_words = len(answer.split())
numeric_tokens = len(re.findall(r"\d[\d.,]*", answer))
# Heuristic score (0 β†’ easy, 1 β†’ hard)
score = 0.0
if dist_q_ctx < self.s.difficulty_easy_threshold:
score += 0.2
elif dist_q_ctx > self.s.difficulty_hard_threshold:
score += 0.6
else:
score += 0.4
# distractors: if >0.75 overlap β†’ hard
if avg_distractor_sim > 0.75:
score += 0.3
elif avg_distractor_sim > 0.55:
score += 0.15
# complex answers bump difficulty
if numeric_tokens > 1 or answer_words > 12:
score += 0.2
score = min(1.0, score)
if score > 0.7:
return "hard"
elif score > 0.4:
return "medium"
return "easy"