eval
js/ai/eval.ts
fino:ai/eval — evaluation cases, scorers, and reporters for AI behavior.
This module runs AI evaluations inside the Fino test runner. evaluate()
registers one test per case plus a trailing summary test, executes an
application-supplied target function on each case input, scores every
output, and asserts that scores clear a threshold. Use it for regression
checks around prompts, tool behavior, workflows, retrieval, and provider
migrations.
Scoring model
A scorer receives the target's output plus the full case and returns either
a bare number or a ScoreResult with pass/explanation metadata. A case's
score is the mean of its scorer values, and the case passes when that mean
reaches the threshold (default 1, meaning every scorer must be perfect).
Built-ins cover exact matches (exactMatch), substring checks (contains),
schema validation (schemaScorer), model-assisted rubric judging
(llmJudge), and embedding similarity (semanticSimilarity).
Reporters are optional sinks layered on top of the test results:
JsonEvalReporter retains a deterministic JSON artifact,
EvalProgressReporter publishes live progress as a signal, and
OpenTelemetryReporter emits GenAI-semconv logs and metrics without
changing the test result semantics.
Evaluations are still tests. Keep cases deterministic where possible, pin or stub models for CI, and treat LLM-judged scores as a policy choice rather than a correctness oracle.
import { contains, evaluate } from 'fino:ai/eval';
evaluate({
name: 'support answer',
cases: [{ name: 'refund policy', input: 'refund window?', expected: '30 days' }],
target: async (input) => askSupportBot(input),
scorers: { mentionsWindow: contains('30 days') },
});
GenAI telemetry conventions: https://opentelemetry.io/docs/specs/semconv/gen-ai/
Interfaces
interface EvalCase< In = unknown, Out = unknown > {
One evaluation case: a named input plus an optional expected output.
name becomes the test name inside the suite that evaluate() registers.
expected feeds scorers that compare against a reference value
(exactMatch, semanticSimilarity, llmJudge); scorers that only inspect
the output ignore it. Extra properties are allowed and travel with the case
into every scorer, so metadata such as tags or difficulty can inform custom
scorers.
import type { EvalCase } from 'fino:ai/eval';
const c: EvalCase<string, string> = {
name: 'refund policy',
input: 'What is the refund window?',
expected: '30 days',
difficulty: 'easy',
};
Properties
name: string
Test name for this case within the evaluation suite.
input: In
Value passed to the evaluation's target function.
expected?: Out
Reference output for scorers that compare against an expected value.
interface ScoreResult {
Result produced by a scorer for one case.
value is the numeric score, conventionally in [0, 1]. pass records the
scorer's own verdict, but evaluate() decides case pass/fail from the mean
of scorer values against its threshold — the per-scorer flag is reporting
metadata, not the test assertion.
import type { ScoreResult } from 'fino:ai/eval';
const result: ScoreResult = {
value: 0.5,
pass: false,
explanation: 'answer omitted the refund window',
};
Properties
value: number
Numeric score, conventionally in [0, 1].
pass: boolean
The scorer's own pass/fail verdict for this output.
explanation?: string
Optional human-readable reason for the score, surfaced in reports.
interface EvalCaseReport< In = unknown, Out = unknown > {
Report for one evaluated case, delivered to EvalReporter.onCase().
scores is keyed by scorer name and holds each scorer's normalized result;
score is the mean of those values and pass records whether the mean
reached the evaluation threshold.
import { EvalReporter, type EvalCaseReport } from 'fino:ai/eval';
class FailureLogger extends EvalReporter {
override async onCase(r: EvalCaseReport): Promise<void> {
if (!r.pass) console.log(`${r.name} scored ${r.score}`, r.scores);
}
}
Properties
name: string
Case name, as declared in EvalCase.name.
input: In
Input the target function was called with.
output: Out
Value the target function returned for this case.
expected?: Out
Reference output from the case, when one was declared.
conversationId?: string
Optional conversation correlation id. evaluate() does not populate it;
custom reporters or pipelines may. The OpenTelemetry reporter maps it to
the gen_ai.conversation.id attribute.
responseId?: string
Optional model response correlation id, mapped to gen_ai.response.id by
the OpenTelemetry reporter. Not populated by evaluate() itself.
scores: Record<string, ScoreResult>
Normalized result of each scorer, keyed by scorer name.
score: number
Mean of the scorer values for this case.
pass: boolean
Whether the mean score reached the evaluation threshold.
interface EvalSummary {
Aggregate result for an evaluation suite, delivered to onFinish().
mean averages the per-case mean scores, passed counts cases whose score
cleared the threshold, and total is the number of cases in the suite.
import { EvalReporter, type EvalSummary } from 'fino:ai/eval';
class SummaryLogger extends EvalReporter {
override async onFinish(s: EvalSummary): Promise<void> {
console.log(`${s.name}: ${s.passed}/${s.total} passed, mean ${s.mean.toFixed(3)}`);
}
}
Properties
name: string
Name of the evaluation suite.
mean: number
Mean of all per-case scores.
passed: number
Number of cases whose score reached the threshold.
total: number
Total number of cases in the suite.
interface EvalProgress {
Snapshot of evaluation progress published by EvalProgressReporter.
completed counts cases scored so far, passed counts those that cleared
the threshold, and mean is the running average score over completed
cases. After the suite finishes, the snapshot matches the final summary
with completed equal to total.
import { EvalProgressReporter, type EvalProgress } from 'fino:ai/eval';
const reporter = new EvalProgressReporter();
reporter.progress.subscribe((p: EvalProgress) => {
console.log(`${p.completed}/${p.total} done, mean ${p.mean.toFixed(3)}`);
});
Properties
name: string
Name of the evaluation suite, empty until onStart() fires.
passed: number
Number of completed cases that passed so far.
total: number
Total number of cases in the suite.
completed: number
Number of cases scored so far.
mean: number
Running mean score over completed cases.
interface JsonEvalReport {
JSON-serializable evaluation report produced by JsonEvalReporter.toJSON().
suite appears once onStart() has fired and summary once onFinish()
has; cases is always present and sorted by case name so serialized
reports diff cleanly between runs.
import { JsonEvalReporter, type JsonEvalReport } from 'fino:ai/eval';
const reporter = new JsonEvalReporter();
// ... run an evaluation with `report: reporter` ...
const report: JsonEvalReport = reporter.toJSON();
console.log(report.summary?.mean, report.cases.length);
Properties
suite?: {
name: string;
cases: number;
}
Suite name and case count, present once the run has started.
cases: EvalCaseReport[]
Per-case reports, sorted by case name.
summary?: EvalSummary
Aggregate result, present once the run has finished.
interface JsonEvalReporterOptions {
Options for JsonEvalReporter.
File output only happens when both path and fs are provided; otherwise
the report is available in memory through toJSON().
import { JsonEvalReporter } from 'fino:ai/eval';
import { DiskFileSystem } from 'fino:file';
const reporter = new JsonEvalReporter({
path: './eval-report.json',
fs: new DiskFileSystem(),
});
Properties
path?: string
File path written after onFinish() when set.
fs?: {
writeFile(path: string, data: string): Promise<void>;
}
File system implementation with a writeFile() method.
Defaults to no file output. Tests can pass DiskFileSystem; applications
may pass another compatible file system.
interface EvalOptions< In = unknown, Out = unknown > {
Options for evaluate().
import { evaluate, exactMatch, contains, type EvalOptions } from 'fino:ai/eval';
const opts: EvalOptions<string, string> = {
name: 'city extraction',
target: async (input) => extractCity(input),
cases: [{ name: 'simple', input: 'I live in Paris', expected: 'Paris' }],
scorers: { exact: exactMatch(), mentionsCity: contains('Paris') },
threshold: 1,
};
evaluate(opts);
Properties
name: string
Name of the test suite registered with the runner.
target: (input: In) => Promise<Out>
Function under evaluation; called once per case with the case's input.
cases: EvalCase < In, Out > []
Cases to run, each becoming one test in the suite.
scorers: Scorer < Out > [] | Record<string, Scorer<Out>>
Scorers applied to every output. A record names each scorer by its key;
an array falls back to each function's scorerName or name.
threshold?: number
Minimum mean score for a case (and for the suite mean) to pass. Also the
cutoff applied when a scorer returns a bare number. Defaults to 1.
report?: EvalReporter
Optional reporter that receives start, per-case, and finish events.
interface OpenTelemetryReporterOptions {
Options for OpenTelemetryReporter.
Provide exporter (a pre-built exporter instance) or endpoint (an
OTLP/HTTP JSON collector URL) to have the reporter start and own a
dedicated OpenTelemetry SDK for the run. With neither, the reporter emits
through whatever logger and meter providers are already registered.
import { OpenTelemetryReporter } from 'fino:ai/eval';
const reporter = new OpenTelemetryReporter({
endpoint: 'http://localhost:4318',
headers: { authorization: `Bearer ${token}` },
});
Properties
endpoint?: string
OTLP/HTTP JSON endpoint used to build an exporter when exporter is not
supplied.
headers?: Record<string, string>
Extra HTTP headers sent with OTLP requests to endpoint.
exporter?: unknown
Pre-built exporter used for spans, logs, and metrics; takes precedence
over endpoint.
resource?: Resource
Resource attached to the SDK the reporter starts. Ignored when no dedicated SDK is started.
Types
type Scorer<Out = unknown> = (
output: Out,
evalCase: EvalCase<unknown, Out>
) => number | ScoreResult | Promise<number | ScoreResult>
Function that scores one evaluation output.
Receives the target's output and the full case (including expected and any
extra metadata). It may return a bare number — which evaluate() normalizes
into a ScoreResult whose pass is value >= threshold — or a complete
ScoreResult, synchronously or as a promise. A scorer that throws fails the
case's test outright.
When scorers are passed to evaluate() as an array, the reported scorer
name comes from a scorerName property on the function (all built-ins set
one), falling back to the function's name. Passing a
Record<string, Scorer> names each scorer by its key instead.
import type { Scorer } from 'fino:ai/eval';
const noApologies: Scorer<string> = (output) => ({
value: output.includes('sorry') ? 0 : 1,
pass: !output.includes('sorry'),
explanation: 'responses should not apologize',
});
Classes
abstract class EvalReporter {
Base class for evaluation report sinks.
evaluate() calls onStart() just before the first case executes,
onCase() after each case is scored, and onFinish() from the trailing
summary test. Every hook is awaited, so a slow reporter delays the tests it
observes. The base implementations are no-ops; subclasses override only the
hooks they care about.
import { EvalReporter, type EvalCaseReport } from 'fino:ai/eval';
class ConsoleReporter extends EvalReporter {
override async onCase(r: EvalCaseReport): Promise<void> {
console.log(`${r.name}: ${r.score.toFixed(3)} ${r.pass ? 'PASS' : 'FAIL'}`);
}
}
Methods
async onStart(_suite: {
name: string;
cases: number;
}): Promise<void>
Called once, just before the first case runs, with the suite name and total case count.
async onCase(_r: EvalCaseReport): Promise<void>
Called after each case has been executed and scored.
async onFinish(_summary: EvalSummary): Promise<void>
Called once from the summary test with the aggregate result.
class EvalProgressReporter extends EvalReporter {
Reporter that publishes live evaluation progress as a retained signal.
Each hook updates the progress signal: onStart() resets it with the
suite name and total, onCase() advances the completed/passed counts and
running mean, and onFinish() snaps the snapshot to the final summary.
Useful for driving UIs or log output that watches a long evaluation run.
import { evaluate, exactMatch, EvalProgressReporter } from 'fino:ai/eval';
const reporter = new EvalProgressReporter();
reporter.progress.subscribe((p) => {
console.log(`${p.completed}/${p.total} (${p.passed} passed)`);
});
evaluate({
name: 'formatter',
cases: [{ name: 'upper', input: 'a', expected: 'A' }],
target: async (input: string) => input.toUpperCase(),
scorers: [exactMatch()],
report: reporter,
});
Getters
get progress(): ReadonlySignal<EvalProgress>
Retained signal holding the latest progress snapshot.
Read .get() for the current value; subscribe() observes subsequent
updates only.
Methods
async onStart(suite: {
name: string;
cases: number;
}): Promise<void>
Resets progress to zero with the suite's name and case count.
async onCase(r: EvalCaseReport): Promise<void>
Advances completed/passed counts and the running mean score.
async onFinish(summary: EvalSummary): Promise<void>
Sets the snapshot to the final summary, marking every case completed.
class JsonEvalReporter extends EvalReporter {
Eval reporter that stores deterministic JSON-friendly reports.
Use this reporter when local CI should write or compare an eval artifact
without sending telemetry to an external service. toJSON() returns cloned
data sorted by case name for stable output, and when both path and fs
are configured the report is also written to disk after onFinish().
import { evaluate, exactMatch, JsonEvalReporter } from 'fino:ai/eval';
import { DiskFileSystem } from 'fino:file';
const reporter = new JsonEvalReporter({
path: './eval-report.json',
fs: new DiskFileSystem(),
});
evaluate({
name: 'formatter',
cases: [{ name: 'upper', input: 'a', expected: 'A' }],
target: async (input: string) => input.toUpperCase(),
scorers: [exactMatch()],
report: reporter,
});
Constructors
constructor(opts: JsonEvalReporterOptions = {})
Creates a reporter; pass path and fs to also write the report to disk
when the run finishes.
Methods
async onStart(suite: {
name: string;
cases: number;
}): Promise<void>
Records the suite name and case count for the report header.
async onCase(r: EvalCaseReport): Promise<void>
Stores a deep JSON clone of the case report, so later mutation of the original cannot affect the artifact.
async onFinish(summary: EvalSummary): Promise<void>
Records the summary and, when path and fs were configured, writes the
pretty-printed JSON report to disk.
toJSON(): JsonEvalReport
Returns the accumulated report as freshly cloned data.
Cases are sorted by name for deterministic output; suite and summary
are included once the corresponding lifecycle hooks have fired. Mutating
the returned object does not affect the reporter's stored state.
class OpenTelemetryReporter extends EvalReporter {
Eval reporter that emits OpenTelemetry GenAI evaluation telemetry.
For each scorer result of each case it records a
gen_ai.client.evaluation.score histogram point and emits a
gen_ai.evaluation.result log event carrying the score value, a
pass/fail label, and the explanation when present. Case-level
conversationId/responseId become gen_ai.conversation.id and
gen_ai.response.id attributes.
When constructed with an exporter or endpoint, onStart() boots a
dedicated OtelSDK and onFinish() flushes and shuts it down, so each run
is self-contained. Without either option the reporter emits through the
ambient logger/meter providers and never owns an SDK. Test outcomes are
unchanged either way — the telemetry is purely additive.
import { evaluate, exactMatch, OpenTelemetryReporter } from 'fino:ai/eval';
evaluate({
name: 'formatter',
cases: [{ name: 'upper', input: 'a', expected: 'A' }],
target: async (input: string) => input.toUpperCase(),
scorers: [exactMatch()],
report: new OpenTelemetryReporter({ endpoint: 'http://localhost:4318' }),
});
Constructors
constructor(opts: OpenTelemetryReporterOptions = {})
Creates a reporter; pass exporter or endpoint to own a dedicated SDK
for the run, or neither to emit through ambient providers.
Methods
async onStart(suite: {
name: string;
cases: number;
}): Promise<void>
Starts a dedicated OpenTelemetry SDK when an exporter or endpoint was configured; otherwise does nothing.
async onCase(r: EvalCaseReport): Promise<void>
Records one histogram point and one log event per scorer result on the
fino.ai.eval meter and logger.
async onFinish(_summary: EvalSummary): Promise<void>
Flushes and shuts down the SDK started by onStart(), if any.
Functions
function evaluate<
In = unknown,
Out = unknown
>(opts: EvalOptions<In, Out>): void
Register an evaluation suite with the Fino test runner.
Call it at module load in a test file; fino test then runs one test per
case plus a trailing summary test. Each case test awaits target(input),
runs every scorer against the output, records the mean scorer value as the
case score (a case with no scorers scores 1), attaches the score as test
metadata, and asserts the mean reaches threshold. The summary test asserts
that the mean over all case scores also reaches the threshold.
When a reporter is supplied, onStart() fires just before the first case
executes, onCase() after each case, and onFinish() from the summary
test. A target or scorer that throws fails that case's test rather than
being converted into a score.
import { evaluate, exactMatch, contains } from 'fino:ai/eval';
evaluate({
name: 'city extraction',
target: async (input: string) => extractCity(input),
cases: [
{ name: 'simple', input: 'I live in Paris', expected: 'Paris' },
{ name: 'trailing punctuation', input: 'Moving to Tokyo!', expected: 'Tokyo' },
],
scorers: { exact: exactMatch(), nonEmpty: contains('') },
threshold: 1,
});
function exactMatch(): Scorer
Scorer for exact equality between the output and the case's expected value.
Compares JSON.stringify(output) against JSON.stringify(expected), so
deep object equality works but property order matters. Scores 1/pass on a
match and 0/fail otherwise. Reports under the name exactMatch.
import { evaluate, exactMatch } from 'fino:ai/eval';
evaluate({
name: 'classifier',
cases: [{ name: 'positive review', input: 'Loved it!', expected: { label: 'positive' } }],
target: async (input) => classify(input),
scorers: [exactMatch()],
});
function contains(substr: string): Scorer
Scorer that checks whether the output text contains substr.
Non-string outputs are JSON-stringified before the check. Scores 1/pass
when the substring is present and 0/fail otherwise; the case's expected
value is not consulted. Reports under the name contains(<substr>).
import { evaluate, contains } from 'fino:ai/eval';
evaluate({
name: 'support answer',
cases: [{ name: 'refund policy', input: 'refund window?' }],
target: async (input) => askSupportBot(input),
scorers: [contains('30 days')],
});
function schemaScorer(schema: unknown): Scorer
Scorer that validates the output against a JSON schema.
The schema is compiled once with fino:validate when the scorer is
created. Valid outputs score 1/pass; invalid outputs score 0/fail with
an explanation listing each validation issue as path: message. Reports
under the name schemaScorer.
import { evaluate, schemaScorer } from 'fino:ai/eval';
evaluate({
name: 'structured extraction',
cases: [{ name: 'contact card', input: 'Ada Lovelace <ada@example.com>' }],
target: async (input) => extractContact(input),
scorers: [schemaScorer({
type: 'object',
properties: { name: { type: 'string' }, email: { type: 'string' } },
required: ['name', 'email'],
})],
});
function llmJudge(model: Model, rubric: string): Scorer
Scorer that asks a model to grade the output against a rubric.
Builds a single-model agent and prompts it with the rubric, the case input,
the expected value (or none when absent), and the output, requesting a
JSON object of the form {"score": 0.0, "explanation": "..."}. The score
is clamped to [0, 1]; a response that is not valid JSON scores 0. The
scorer's own pass flag is set only for a perfect 1, but evaluate()
judges the case by mean score against its threshold, so partial credit
still counts. Reports under the name llmJudge.
Judged scores inherit the judge model's variance — pin or stub the model in CI and treat the rubric as policy rather than ground truth.
import { evaluate, llmJudge } from 'fino:ai/eval';
import { anthropic } from 'fino:ai/model';
evaluate({
name: 'tone check',
cases: [{ name: 'greeting', input: 'Say hi to Ada', expected: 'A warm greeting addressed to Ada' }],
target: async (input) => runAssistant(input),
scorers: [llmJudge(anthropic(), 'The answer must be polite and match the expected description.')],
threshold: 0.8,
});
function semanticSimilarity(model: EmbeddingModel, min: number): Scorer
Scorer that compares the output and expected value by embedding similarity.
Embeds both texts with the given embedding model (non-strings are
JSON-stringified first) and scores their cosine similarity, passing when
the similarity reaches min. A case without an expected value scores
0/fail with a 'no expected value' explanation. Reports under the name
semanticSimilarity(<min>).
Because the raw cosine value is the score, pair this scorer with a
threshold below 1 — identical wording is rarely required, only meaning.
import { evaluate, semanticSimilarity } from 'fino:ai/eval';
import { openai } from 'fino:ai/model';
evaluate({
name: 'paraphrase quality',
cases: [{ name: 'refund', input: 'refund window?', expected: 'Refunds are accepted within 30 days.' }],
target: async (input) => paraphraseBot(input),
scorers: [semanticSimilarity(openai(), 0.85)],
threshold: 0.85,
});