Docs menu
Evaluation: scorers & LLM-judge
Scores runs deterministically from the journal trace.
What it's for / when to use it#
@gnldev/evals scores an agent run's output from the journal trace (the recorded model/tool history) — it looks at the journal, not the live call. That's why scoring is deterministic and replayable: every time the same journal runs, rule-based scorers (exactMatch, contains, regex, embeddingSimilarity) give the same score; even an LLM-judge result invoked via scoreRun is memoized to the journal, so a resume doesn't make another model call — it returns the first score.
Typical scenario: run an agent over N test cases in a dataset, score each case, and take the average — that's what evalDataset is for. If the suite is called with a journal, each case is memoized durably: if the suite crashes partway through, completed cases don't re-run, the rest continue where they left off (a resumable eval suite).
Setup / import#
The package has a peer-dependency on @gnldev/durable (for reading/writing the journal); LLM-judge also requires ai (the AI SDK).
pnpm add @gnldev/evals @gnldev/durable aiimport { scoreRun, exactMatch, contains, regexScore, embeddingSimilarity, llmJudge, evalDataset } from '@gnldev/evals';
import {
faithfulness, hallucination, answerRelevancy, toxicity, bias, completeness, contextPrecision, toneConsistency,
} from '@gnldev/evals';
import { createDatasetsManager } from '@gnldev/evals';Step-by-step usage#
1) Score a completed run — scoreRun reads the run's journal trace through a JournalReader, extracts the text of the last model entry, and applies the given scorers to that text:
import { scoreRun, exactMatch, llmJudge } from '@gnldev/evals';
const res = await scoreRun(reader, runId, [
exactMatch(),
llmJudge({ model, rubric: 'Is the answer correct and concise?' }),
], { expected: 'Paris' });
// res.output -> the last model output in the journal
// res.scores -> { 'exact-match': { score, reason }, 'llm-judge': { score, reason } }If reader also satisfies the Journal interface (e.g. a SqliteStorage instance), scoreRun memoizes each scorer's result to the journal under the key `${runId}:proc:eval:${name}` — so if the same run is scored again (or resumed), even llmJudge doesn't make a second model call. You can turn this off with { memo: false }.
2) Batch evaluation over a dataset — evalDataset runs the agent on every case in the dataset, scores them, and totals the average per scorer:
import { evalDataset } from '@gnldev/evals';
const report = await evalDataset({
dataset: { id: 'qa-suite', cases: [
{ id: 'c1', input: 'What is the capital of France?', expected: 'Paris' },
] },
run: async (input, { runId }) => {
// run the agent for this case; return { output } or a string
return runAgent(input, { runId });
},
scorers: [exactMatch()],
journal, // when given, case results are memoized -> a resumable suite
});
// report.aggregate -> { 'exact-match': 0.8 } (0..1 ortalama)When the journal parameter is given, each case is memoized under `evalds:${dataset.id}` with the key `case:${caseId}`; if there's a crash partway through the suite, completed cases are returned from the journal instead of being re-run.
3) Rule-based scorers on their own — these functions, which implement the Scorer interface, can also be called directly without scoreRun/evalDataset:
const exact = exactMatch();
await exact.score({ output: 'Paris', expected: 'Paris' }); // { score: 1, reason: 'exact match' }
const emb = embeddingSimilarity(
(text) => embed({ model: embeddingModel, value: text }).then((r) => r.embedding),
{ threshold: 0.85 },
);
await emb.score({ output: '...', expected: '...' });4) Built-in scorers — the 8 built-in scorers are rubrics built on top of llmJudge (faithfulness, hallucination, answerRelevancy, toxicity, bias, completeness, contextPrecision, toneConsistency) — none of them call generateText directly, they wrap llmJudge; that's why scoreRun/dataset memoization comes for free.
import { faithfulness, hallucination, toxicity } from '@gnldev/evals';
const res = await scoreRun(reader, runId, [
faithfulness({ model }), // sample.context gerektirir — yoksa { score: 0, reason: 'context gerekli: ...' }
hallucination({ model }), // needs context; DIRECTION: 1.0 = NO hallucination (good)
toxicity({ model }), // needs neither context nor input; it scores the output alone
], { context: retrievedChunks });Direction semantics (common to every scorer): a high score = a good result. This can be confusing for scorers whose name suggests the opposite (hallucination, toxicity, bias): e.g. a hallucination score of 1.0 means "NO hallucination", not "hallucination present".
Scorers that need context (faithfulness, hallucination, contextPrecision) require sample.context; those that need a question (answerRelevancy, completeness) require sample.input — if missing, they don't silently return 1.0, they return { score: 0, reason: '...' }. Every scorer also explicitly declares via sampleFields which extra fields (input/context) get added to the llmJudge prompt — toxicity/bias/toneConsistency deliberately use sampleFields: [] (so they aren't contaminated by irrelevant context — e.g. if the context is toxic, the output shouldn't get a falsely low toxicity score even if it's clean).
5) Dataset versioning + experiment comparison — createDatasetsManager adds a journal-based layer on top of evalDataset: it keeps a dataset's version history, records experiments idempotently, and compares two experiments (regression/improvement).
import { createDatasetsManager } from '@gnldev/evals';
const manager = createDatasetsManager(journal); // journal 'listKeys' desteklemeli
await manager.saveDataset(dataset); // no new version is opened when the content is unchanged (hash comparison)
const exp1 = await manager.runExperiment({
datasetId: 'qa-suite', run: runAgent, scorers: [exactMatch()], experimentId: 'baseline',
});
const exp2 = await manager.runExperiment({
datasetId: 'qa-suite', run: runAgentV2, scorers: [exactMatch()], experimentId: 'candidate', label: 'yeni prompt',
});
// a second call with the same experimentId does NOT re-run evalDataset — it returns the recorded result (idempotent)
const diff = await manager.compare('qa-suite', 'baseline', 'candidate');
// diff.aggregate -> per scorer { baseline, candidate, delta }
// diff.regressions / diff.improvements -> how many cases got worse or betterScorers that cost nothing to run#
Two families here never call a model. The four text scorers — contentSimilarity, keywordCoverage, textualDifference, answerSimilarity — are pure functions of output and expected: no cost, no latency, and no non-determinism to reason about. They are the right default for a large regression suite where an LLM judge would be both slow and expensive.
The trajectory scorers answer a different question: not "is the answer good" but "did the agent take a sensible path". createTrajectoryScorer scores the tool-call sequence against four dimensions — an ordered subsequence it should contain, tools that must appear in any order, tools that must not appear at all, and a call budget. Exceeding the budget or hitting a forbidden tool degrades the score proportionally rather than zeroing it.
It reads the same decision sequence the regression diff uses (buildDecisionSequence), so "a decision point" means one thing across the whole system rather than two similar things in two packages.
import { createTrajectoryScorer, trajectoryScorerFor, contentSimilarity } from '@gnldev/evals';
const path = createTrajectoryScorer({
expectedTools: ['searchDocs', 'summarise'], // ordered subsequence; extras in between are fine
requiredTools: ['citeSource'], // must appear, order irrelevant
forbiddenTools: ['deleteRecord'], // a hit degrades the score proportionally
maxToolCalls: 8, // over budget degrades, it does not zero
});
// bound to a reader: the sample carries a runId and the scorer looks the run up
const fromJournal = trajectoryScorerFor(reader, { requiredTools: ['citeSource'] });
await evalDataset({ dataset, run, scorers: [path, contentSimilarity()], journal });API reference#
scoreRunReads a run's last model output from the journal and scores it with the given scorers; memoizes the results if the journal is writable.
createTrajectoryScorerA pure Scorer over the tool-call sequence: expectedTools (ordered subsequence — extra calls in between are fine), requiredTools (any order), forbiddenTools, maxToolCalls, and per-dimension weights. No model call, so the same sequence always scores the same.
trajectoryScorerForThe Scorer adapter bound to a JournalReader: give a sample a runId and it looks the run up itself.
scoreTrajectoryscoreTrajectory(reader, runId, opts) — reads the run, rebuilds its decision sequence and scores it. A one-shot function, not a Scorer.
scoreToolSequenceThe same scoring as a one-shot function over a plain string[] of tool names — usable in a unit test with a hand-built sequence.
contentSimilarityModel-free token-overlap similarity between output and expected.
keywordCoverageHow much of a keyword list the output covers. Without an explicit list the keywords are derived from sample.expected.
textualDifferenceModel-free difference score — the inverse framing of similarity.
answerSimilarityModel-free answer-level similarity between output and expected.
ScoreRunResultscoreRun's return value: { runId, output, scores }.
exactMatchWhether the output is a (trimmed) exact match of expected.
containsWhether the output contains the given substring (or expected).
regexScoreWhether the output matches the given regex.
embeddingSimilarityVectorizes output/expected with an embed function and returns cosine similarity (with an optional threshold for pass/fail).
llmJudgeAsks an AI SDK model to score the output 0.0-1.0 against the given rubric and parses the SCORE/REASON format.
LlmJudgeOptions{ model, rubric?, name?, sampleFields? } — LlmJudge input. 'sampleFields' picks which parts of the sample reach the judge's prompt (default ['input','context']); a scorer that must not be swayed by the context — toxicity, for one — passes [] deliberately.
faithfulness(opts: JudgeScorerOptions) → Scorer. Measures the output's faithfulness to sample.context; 0 + a reason if context is missing. DIRECTION: 1.0 = fully faithful.
hallucinationMeasures whether the output contains claims that contradict or fabricate beyond the context; requires context. DIRECTION (counter to the name): 1.0 = NO hallucination.
answerRelevancyMeasures how focused the output's answer is on sample.input (the question); requires input.
toxicityMeasures whether the output contains abuse/hate speech; doesn't require context/input. DIRECTION (counter to the name): 1.0 = NOT toxic.
biasMeasures whether the output contains group-based bias; doesn't require context/input. DIRECTION (counter to the name): 1.0 = NO bias.
completenessMeasures whether the output fully covers every aspect of sample.input; requires input.
contextPrecisionMeasures how much of the retrieved context chunks are relevant/necessary (retrieval quality); requires context.
toneConsistency(opts: ToneConsistencyOptions) → Scorer. Measures tone consistency throughout the output (with an optional expectedTone).
JudgeScorerOptions{ model, name? } — the shared factory options for the 8 built-in scorers.
ToneConsistencyOptionsJudgeScorerOptions + { expectedTone? } — specific to toneConsistency.
evalDatasetTakes { dataset, run, scorers, journal?, scope? }; runs every case, scores it, and returns the per-scorer average as aggregate.
Dataset{ id, cases: DatasetCase[] }.
DatasetCase{ id, input, expected?, metadata? }.
EvalDatasetResult{ datasetId, cases: EvalCaseResult[], aggregate }.
Scorer{ name, score(sample) } — sample: { output, expected?, input? }; score returns 0.0-1.0.
createDatasetsManager(journal) → { saveDataset, getDataset, listVersions, runExperiment, getExperiment, listExperiments, compare }. journal must support 'listKeys'.
DatasetVersion{ version, at, dataset } — saveDataset/getDataset's return value; if the content hasn't changed, no new version is opened (hash comparison).
ExperimentRecord{ id, datasetId, datasetVersion, at, label?, result } — a runExperiment record; a second run with the same experimentId is idempotent (not re-run).
RunExperimentOptions{ dataset?, datasetId?, run, scorers, experimentId?, label?, now? } — runExperiment's input.
ExperimentDiff{ datasetId, baseline, candidate, aggregate, changes, regressions, improvements } — compare()'s return value; changes is sorted with the worst regression first.
scoreRun (when a Journal/JournalReader is passed) — calling llmJudge(...).score(...) directly makes a real model request every time.embeddingSimilarity expects an embed provider — GNL doesn't include its own embed function; the AI SDK's embed() (or an equivalent) must be supplied externally.createDatasetsManager's runExperiment gives evalDataset an experiment-specific scope (`${datasetId}:exp:${experimentId}`) so that different experiments on the same dataset don't see each other's memoized cases — a crash-resume of the same experiment still continues where it left off (the resumable suite property is preserved).