Deryks _dev

Full-Stack Web Developer & Technical Leader

Reference / Evaluating a Socratic Tutor

JevEval Cheat Sheet

Everything needed to design, score and debug a JevEval metric. Source: the deepeval JevEval docs, unless marked otherwise. Print this one.

The evaluation loop

StepWhat it means
1. SpecThe behaviour you want: the tutor's system prompt, verbatim in NOTES.md.
2. Test casesConversations that trigger each rule, including adversarial ones. Look at lots of outputs before trusting any metric (Husain).
3. QuestionsOne bounded question per rule, weighted by how much you care.
4. Run and readThe total says whether something failed; the per-question breakdown says which rule.
5. IterateChange the prompt or the question, then rerun the same test cases.

Question types

TypeUse whenValueGotcha
NoulA true-or-false statement. Use several for a checklist.P(true)Phrase it so true is the behaviour you want.
ScoreOrdered outcomes, where "in between" means something. 2–10 levels.Σ k·P(k) / (n−1)Levels worst first, or the score silently inverts.
ChoiceUnordered outcomes; options may share a credit; the behaviour may not come up at all.Σ P(o)·c(o) / Σ P(o), over options with a creditDrops out if the None options hold ≥ 0.5.

The score

JevEval = Σ wᵢ·vᵢ / Σ wᵢ     over applicable questions only
RuleDetail
Nothing appliesScore is 1: nothing applicable was left to fail.
Passscore ≥ threshold. Default threshold 0.5; None runs the metric score-only.
strict_mode=TrueScore is 0 or 1, and the threshold becomes 1.
What Jev can see"A field that is not in evaluation_params is invisible to Jev, so every field your questions mention must be listed."
Weights and creditsStay in your code. Jev only ever sees the questions and the option descriptions.

TypeScript: the arithmetic

From lesson 1, where every line was run against the docs' worked example and printed their 0.461.

src/jev-eval.tsthe scoring functions

type Question = { name: string; weight: number; value: number | null };

// A weighted mean of every question's value.
function jevEval(questions: Question[]): number {
  let weighted = 0;
  let totalWeight = 0;
  for (const q of questions) {
    if (q.value === null) continue; // this question doesn't apply
    weighted += q.weight * q.value;
    totalWeight += q.weight;
  }
  if (totalWeight === 0) return 1; // nothing applicable was left to fail
  return weighted / totalWeight;
}

// A Score's value is the expected level, counted from 0, over the top level.
function scoreValue(levels: string[], answer: Record<string, number>): number {
  let expected = 0;
  for (const [level, label] of levels.entries()) {
    expected += level * (answer[label] ?? 0);
  }
  return expected / (levels.length - 1);
}

type Credits = Record<string, number | null>;

// A Choice's value is the credit Jev's answer earns, averaged over the options
// that count. When the null options hold half the probability or more, the
// question doesn't apply, and the value is null.
function choiceValue(
  credits: Credits,
  answer: Record<string, number>
): number | null {
  let noneMass = 0;
  let mass = 0;
  let earned = 0;
  for (const [option, credit] of Object.entries(credits)) {
    const p = answer[option] ?? 0;
    if (credit === null) {
      noneMass += p;
      continue;
    }
    mass += p;
    earned += p * credit;
  }
  if (noneMass >= 0.5) return null;
  return earned / mass;
}

const missingCredits: Credits = {
  left_it_out: 1,
  flagged_it_as_unknown: 1,
  hedged_it: 0.5,
  stated_it_as_fact: 0,
  nothing_missing: null,
};
Not here yet: the Jev requestThe request that fills in these probabilities is added after lesson 2 runs it. Until then, two things are unchecked: what answer.score means, and whether a Score's probabilities are keyed by position or by label. There are also two routes to choose between: the OpenRouter decisions API from exercise 21, and the@typesafe-ai/sdk package the docs list (with TYPESAFE_API_KEY).

Python: deepeval

pip install typesafe-sdk          # the docs' command; deepeval itself as well
export TYPESAFE_API_KEY=...
Single turnConversation
JevEval + LLMTestCase
params: SingleTurnParams
ConversationalJevEval + ConversationalTestCase(scenario, expected_outcome, turns=[Turn(role, content)])
params: MultiTurnParams; ROLE and CONTENT are added automatically
from deepeval.metrics.jev_eval import Noul, Score, Choice
from deepeval.metrics import JevEval, ConversationalJevEval
from deepeval.test_case import SingleTurnParams, MultiTurnParams

metric.measure(test_case)
print(metric.score, metric.reason)
print(metric.score_breakdown)
# each entry: question, type, weight, value, applicable, probabilities, confidence

from deepeval import evaluate
evaluate(test_cases=[test_case], metrics=[metric])

Questions about a conversation talk about turns as a whole ("by the end of turns", "at any point in turns") or about a role ("the assistant", "the user").

The worked example

QuestionWeightJev's answerValue
Noul: every fact appears in a tool output2P(true) 0.300.30
Noul: every tool value reported accurately1P(true) 0.900.90
Score: how grounded, 4 levels10.05 / 0.30 / 0.55 / 0.101.7 ÷ 3 = 0.567
Choice: what it did with missing information1None option 0.050.225 ÷ 0.95 = 0.237
(2 · 0.30 + 0.90 + 0.567 + 0.237) ÷ 50.461