GNL
Docs menu
Core · Free@gnldev/evals

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).

npm/pnpm
pnpm add @gnldev/evals @gnldev/durable ai
import
import { 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:

Scoring with scoreRun
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:

Running a suite with evalDataset
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:

calling a scorer directly
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.

built-in scorers
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).

createDatasetsManager
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 better

Scorers that cost nothing to run#

Two families here never call a model. The four text scorerscontentSimilarity, 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.

a trajectory scorer
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#

fnscoreRun

Reads a run's last model output from the journal and scores it with the given scorers; memoizes the results if the journal is writable.

fncreateTrajectoryScorer

A 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.

fntrajectoryScorerFor

The Scorer adapter bound to a JournalReader: give a sample a runId and it looks the run up itself.

fnscoreTrajectory

scoreTrajectory(reader, runId, opts) — reads the run, rebuilds its decision sequence and scores it. A one-shot function, not a Scorer.

fnscoreToolSequence

The same scoring as a one-shot function over a plain string[] of tool names — usable in a unit test with a hand-built sequence.

fncontentSimilarity

Model-free token-overlap similarity between output and expected.

fnkeywordCoverage

How much of a keyword list the output covers. Without an explicit list the keywords are derived from sample.expected.

fntextualDifference

Model-free difference score — the inverse framing of similarity.

fnanswerSimilarity

Model-free answer-level similarity between output and expected.

typeScoreRunResult

scoreRun's return value: { runId, output, scores }.

fnexactMatch

Whether the output is a (trimmed) exact match of expected.

fncontains

Whether the output contains the given substring (or expected).

fnregexScore

Whether the output matches the given regex.

fnembeddingSimilarity

Vectorizes output/expected with an embed function and returns cosine similarity (with an optional threshold for pass/fail).

fnllmJudge

Asks an AI SDK model to score the output 0.0-1.0 against the given rubric and parses the SCORE/REASON format.

typeLlmJudgeOptions

{ 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.

fnfaithfulness

(opts: JudgeScorerOptions) → Scorer. Measures the output's faithfulness to sample.context; 0 + a reason if context is missing. DIRECTION: 1.0 = fully faithful.

fnhallucination

Measures whether the output contains claims that contradict or fabricate beyond the context; requires context. DIRECTION (counter to the name): 1.0 = NO hallucination.

fnanswerRelevancy

Measures how focused the output's answer is on sample.input (the question); requires input.

fntoxicity

Measures whether the output contains abuse/hate speech; doesn't require context/input. DIRECTION (counter to the name): 1.0 = NOT toxic.

fnbias

Measures whether the output contains group-based bias; doesn't require context/input. DIRECTION (counter to the name): 1.0 = NO bias.

fncompleteness

Measures whether the output fully covers every aspect of sample.input; requires input.

fncontextPrecision

Measures how much of the retrieved context chunks are relevant/necessary (retrieval quality); requires context.

fntoneConsistency

(opts: ToneConsistencyOptions) → Scorer. Measures tone consistency throughout the output (with an optional expectedTone).

typeJudgeScorerOptions

{ model, name? } — the shared factory options for the 8 built-in scorers.

typeToneConsistencyOptions

JudgeScorerOptions + { expectedTone? } — specific to toneConsistency.

fnevalDataset

Takes { dataset, run, scorers, journal?, scope? }; runs every case, scores it, and returns the per-scorer average as aggregate.

typeDataset

{ id, cases: DatasetCase[] }.

typeDatasetCase

{ id, input, expected?, metadata? }.

typeEvalDatasetResult

{ datasetId, cases: EvalCaseResult[], aggregate }.

typeScorer

{ name, score(sample) } — sample: { output, expected?, input? }; score returns 0.0-1.0.

fncreateDatasetsManager

(journal) → { saveDataset, getDataset, listVersions, runExperiment, getExperiment, listExperiments, compare }. journal must support 'listKeys'.

typeDatasetVersion

{ version, at, dataset } — saveDataset/getDataset's return value; if the content hasn't changed, no new version is opened (hash comparison).

typeExperimentRecord

{ id, datasetId, datasetVersion, at, label?, result } — a runExperiment record; a second run with the same experimentId is idempotent (not re-run).

typeRunExperimentOptions

{ dataset?, datasetId?, run, scorers, experimentId?, label?, now? } — runExperiment's input.

typeExperimentDiff

{ datasetId, baseline, candidate, aggregate, changes, regressions, improvements } — compare()'s return value; changes is sorted with the worst regression first.

Tip
LLM-judge is only memoized to the journal if it's invoked via scoreRun (when a Journal/JournalReader is passed) — calling llmJudge(...).score(...) directly makes a real model request every time.
Warning
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.
Scope
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).