Skip to content

Evaluation

The @open-multi-agent/core/eval subpath measures agent and multi-agent quality offline or through best-effort online sampling. Evaluation observes completed results; it never changes the business result.

Use runtime verification and evaluation for different jobs:

runConsensus() / per-task verifyEvaluation
WhenDuring one business runOffline in batches or asynchronously after a live run
Changes the business resultYes: may accept, revise, or rejectNo
MeasuresOne resultCases, versions, regressions, and trends
Failure meaningAffects the runtime verdictProduces scorer_error; never becomes score zero
OutputConsensusResultEvalRecord, EvalRunReport, and GateVerdict

The two mechanisms compose: verification protects a single run, while an EvalSet can detect changes in verification pass rate over time.

import { defineScorer, type ScorerContext } from '@open-multi-agent/core/eval'
const exact = defineScorer({
name: 'exact-match',
version: '1',
score({ output, evalCase }) {
const hit = output === evalCase.expected
return { score: hit ? 1 : 0, pass: hit }
},
})
const context: ScorerContext = {
evalCase: { id: 'capital-france', input: 'Capital of France?', expected: 'Paris' },
output: 'Paris',
metadata: { promptVersion: 'v2' },
signal: new AbortController().signal,
}
const result = await exact.score(context)
console.log(result.score) // 1

Scores must be finite numbers from 0 through 1. pass is optional so a later gate can apply its own threshold. defineScorer() freezes the scorer definition and validates both synchronous and asynchronous results. A scorer may omit version, but OMA warns once per scorer name because a gate then cannot distinguish scoring-logic drift from target drift. Bump the version whenever a rule, prompt, judge model, or judge configuration changes.

A scorer that throws, rejects, or exceeds its timeout has not measured quality. runEvalSet() records that outcome as an EvalRecord with status: 'scorer_error', normalizes the error, continues to later scorers, and excludes the failure from score averages, percentiles, and pass rates. Do not replace scorer failures with { score: 0 }.

If the target itself throws, the sample produces one target_error record under the reserved scorer name _target; its scorers do not run. The eval subpath also defines the EvalRecord shape and schema major version.

Reference scorers are deliberately small examples, not universal quality standards. Import them from @open-multi-agent/core/eval:

FactoryScore meaningRequired data and missing-data behavior
toolCallSuccessScorer()Successful tool spans / all tool spansUses trace status when available. A result-only ToolCallRecord has no error flag, so completed result calls are treated as successful. No calls returns 1 with details.tool_calls = 0 and an explicit not-applicable reason.
structuredOutputComplianceScorer(schema?)1 when AgentRunResult.structured exists and, when supplied, passes the Zod schemaIntended only for targets whose agent config has outputSchema. Missing structured output is a measured failure (0), not missing infrastructure.
costBudgetScorer({ maxTokens?, maxCostAmount? })Hard step: 1 within every observable ceiling, otherwise 0Tokens come from the OMA result; cost comes from StoredRun.costs. Unavailable dimensions are named in reason and details.data_complete; no observable dimension returns 1 with applicable: false. Multiple currencies throw and become scorer_error instead of being added incorrectly.
dependencyUtilizationScorer()Completed dependency-bearing task spans / all dependency-bearing task spansRequires a trace. This is a conservative dependency-chain completion proxy; it proves linked prerequisites and the dependent task completed, not that the model semantically used prerequisite text.
duplicateWorkScorer({ threshold? })1 - duplicatePairs / comparedPairsRequires both a trace and TeamRunResult. Trace identifies task IDs; actual outputs come from agentResults. Similarity is Jaccard over normalized character trigrams. Fewer than two outputs returns explicit not-applicable 1.
noProgressScorer({ maxStallTurns? })1 within the allowed consecutive stalls; above it, maxStallTurns / observedMaximumRequires a trace. A stall is a failed task-agent attempt with LLM work, no tool call, and no completed task. This measures agent attempts, not semantic reasoning turns.
createAnswerRelevancyScorer({ judges, ... })Mean judge score for direct relevance to input and expected outputThin createJudgeScorer() wrapper with a fixed { score, reason } schema. Treat it as a prompt template to version and validate against your own data.

The three structure-aware scorers expose behavior specific to a multi-agent DAG, but remain privacy-aware: traces do not persist task output bodies. Dependency utilization and no-progress therefore use honest structural proxies, while duplicate-work reads outputs from the in-memory TeamRunResult and uses the trace only to select task executions. Use these three with offline runEvalSet(..., { traceStore }); online sampling does not load a trace for a scorer.

import {
createAnswerRelevancyScorer,
toolCallSuccessScorer,
} from '@open-multi-agent/core/eval'
const scorers = [
toolCallSuccessScorer(),
createAnswerRelevancyScorer({
version: 'relevancy-prompt-v1',
judges: [{ name: 'judge', model: 'claude-sonnet-4-6', provider: 'anthropic' }],
}),
]

Quick start: offline evaluation in five minutes

Section titled “Quick start: offline evaluation in five minutes”
import {
defineEvalSet,
defineScorer,
runEvalSet,
type EvalTarget,
} from '@open-multi-agent/core/eval'
const set = defineEvalSet({
name: 'greetings',
version: '1.0.0',
cases: [
{ id: 'a', input: 'hi', expected: 'HI', tags: ['upper'] },
{ id: 'b', input: 'yo', expected: 'YO', tags: ['upper'] },
],
defaults: { concurrency: 2 },
})
const target: EvalTarget = async (input) => ({
output: String(input).toUpperCase(),
})
const exact = defineScorer({
name: 'exact',
version: '1',
score({ output, evalCase }) {
const pass = output === evalCase.expected
return { score: pass ? 1 : 0, pass }
},
})
const report = await runEvalSet(set, target, {
scorers: [exact],
repeats: 2,
metadata: { prompt_version: 'v2' },
})
console.log(report.records.length) // 4
console.log(report.aggregates[0]?.avg) // 1
console.log(report.aggregates[0]?.passRate) // 1

defineEvalSet() validates non-empty names, versions, and cases; requires case IDs to be unique; and returns a deeply frozen copy. Treat version as the content version and bump it whenever cases change. filterTags selects cases matching any requested tag. repeats and concurrency override the set defaults.

Each case/repeat target runs once, then that sample’s scorers run serially. Different samples run in parallel up to concurrency (default 2). Aborting stops new samples from being scheduled, waits for already-started samples, and returns a partial report with aborted: true.

Report percentiles use the nearest-rank method. For two sorted scores, p50 is the lower score and p95 is the higher score. passRate only includes scored records that explicitly contain pass; scorer errors are excluded from every score denominator. byTag repeats the same aggregation for each case tag. Target token usage is counted once per sample, even when multiple scorers run, and costs are summed only within the same currency.

Online evaluation is opt-in on OpenMultiAgent. A settled top-level run only performs a synchronous sampling decision and bounded queue admission; scorers and store writes run later and never change the business result.

import { OpenMultiAgent } from '@open-multi-agent/core'
import {
InMemoryEvalStore,
defineScorer,
} from '@open-multi-agent/core/eval'
const onlineStore = new InMemoryEvalStore()
const lengthScorer = defineScorer({
name: 'length',
version: '1',
score({ output }) {
const length = String(output).length
return { score: Math.min(1, length / 200), pass: length >= 40 }
},
})
const orchestrator = new OpenMultiAgent({
evaluation: {
scorers: [lengthScorer],
sample: 0.05,
maxConcurrent: 1,
maxQueueLength: 100,
budget: { maxEvaluationsPerMinute: 30 },
store: onlineStore,
},
})
const run = await orchestrator.runAgent(agent, prompt)
// The business result does not wait for lengthScorer or onlineStore.
console.log(run.success)
await orchestrator.evaluation.forceFlush({ timeoutMs: 1_000 })
const page = await onlineStore.query({ runId: [run.identity!.runId] })
console.log(page.items[0]?.source) // online

runAgent, runTeam, runTasks, runFromPlan, runConsensus, and restore all use the same evaluator owned by the OpenMultiAgent instance. Its evalRunId therefore remains stable for that instance lifetime. Each sampled run produces one EvalRecord per scorer with source: 'online', no EvalSet or case ID, and a runRef containing the exact logical run and attempt.

Numeric sampling uses Math.random() < sample. A rule can select by normalized status and validated run metadata without implementing tail sampling:

const failuresOnly = new OpenMultiAgent({
evaluation: {
scorers: [lengthScorer],
sample: (context) =>
context.status.code !== 'ok'
&& context.metadata['deployment'] === 'canary',
store: onlineStore,
},
})

A throwing sampling rule is treated as false and diagnosed. A scorer throw, rejection, or timeout produces a scorer_error record. A rejected store append drops that sample’s record batch. Queue overflow, exhausted budgets, callbacks, and all evaluation failures are isolated from the original run result.

Online defaults are deliberately conservative: evaluation is off when the configuration is omitted or sample is 0; maxConcurrent is 1, maxQueueLength is 100, payload persistence is none, diagnostics warn at most once per code per 60 seconds, and there is no implicit rate or cost cap. diagnostics: 'silent' must be explicit. getStats() returns cumulative sampled, enqueued, completed, dropped, failed, and storeFailed counts.

maxEvaluationsPerMinute counts scorer evaluations, so one sampled run with three scorers consumes three units. maxCostPerHour uses the caller’s existing OrchestratorConfig.estimateCost function and the model usage surfaced by framework-backed scorers such as createJudgeScorer. The cap uses the same caller-defined unit returned by estimateCost, can overshoot by currently running scorer work, and resumes as the rolling hour advances. Rule scorers without model usage cost zero. Custom model-backed scorers cannot be costed unless they use a framework scorer that reports its internal usage. Configuring maxCostPerHour without estimateCost leaves the cap inactive and emits one payload-free warning; it never silently blocks runs.

storePayloads: 'none' gives scorers a content-free run-input description and does not persist input or output. 'redacted' gives scorers and the record a bounded, redacted input string and persists a bounded, redacted output; 'full' does the same without redaction and must be an explicit privacy decision. Scorers always receive the candidate output in memory so they can score it. In particular, a judge scorer sends that output to its configured model provider; do not enable an external judge for data that may not leave its trust boundary.

The application owns evaluator lifecycle. OMA installs no signal handlers and does not call process.exit(). All evaluator timers are unreferenced, so they do not keep a CLI or serverless process alive. This also means a process crash or natural exit can lose queued work: the first implementation is in-process, best-effort, and intentionally not durable. A durable or cross-process scoring queue is a separate future integration, not an EvalStore guarantee.

// Serverless/FaaS: flush this invocation; keep a shared singleton usable.
const result = await orchestrator.runAgent(agent, prompt)
const evaluation = await orchestrator.evaluation.forceFlush({ timeoutMs: 1_500 })
return { result, evaluation: evaluation.status }
// Short-lived CLI: settle accepted samples before natural process exit.
try {
await main()
} finally {
await orchestrator.evaluation.forceFlush({ timeoutMs: 5_000 })
await orchestrator.evaluation.shutdown({ timeoutMs: 5_000 })
}
// Long-lived server: stop traffic, then drain and close on graceful shutdown.
async function stopServer() {
await stopAcceptingAndWaitForInflight(server)
await orchestrator.evaluation.forceFlush({ timeoutMs: 10_000 })
await orchestrator.evaluation.shutdown({ timeoutMs: 10_000 })
await provider?.shutdown()
}
// Register stopServer with your server/process framework if desired.

forceFlush() waits for the samples accepted before its watermark and returns ok, partial, timeout, or error plus cumulative counts. shutdown() atomically rejects new samples, flushes its cutoff, and is idempotent: repeated or concurrent calls share the first result. OpenMultiAgent.shutdown() remains the existing team-registry reset; evaluator shutdown is explicit through orchestrator.evaluation.

On the online-evaluation maintainer benchmark (Node 22, 50,000 same-process direct admissions), sampling plus bounded enqueue measured approximately 0.42 µs p95 (0.30 µs mean) on the implementation host. Absolute microseconds vary by host; CI additionally retains the existing observability same-host regression gate for the unconfigured path.

Use InMemoryEvalStore for short-lived local runs, tests, or an adapter prototype. Pass it to runEvalSet() to persist one atomic batch per completed case/repeat sample:

import {
InMemoryEvalStore,
runEvalSet,
} from '@open-multi-agent/core/eval'
const store = new InMemoryEvalStore()
const storedReport = await runEvalSet(set, target, {
scorers: [exact],
store,
})
const first = await store.query({
evalRunId: storedReport.evalRunId,
scorer: ['exact'],
order: 'time_asc',
limit: 100,
})

EvalStore.append() is atomic per batch and idempotent by recordId. Queries can filter by evaluation run, referenced OMA run, EvalSet name, scorer, source, status, and inclusive after / exclusive before timestamps. Results use the stable (timestampUnixMs, recordId) order. The default page limit is 100 and the maximum is 1,000.

Cursors are opaque snapshots. Appends after the first page do not create gaps or duplicates in that pagination sequence. A cursor is valid only for the same store instance and normalized query; changing filters, deleting records, or reopening a file store invalidates it. Do not parse or persist cursors as data.

The optional InMemoryEvalStore({ maxRecords }) capacity is a hard limit. A batch that would exceed it is rejected atomically. Use retention explicitly when eviction is intended:

await store.applyRetention({
maxAgeMs: 30 * 24 * 60 * 60 * 1_000,
maxRecords: 10_000,
sources: ['offline'],
})
await store.delete({
evalSetName: 'greetings',
before: new Date('2026-01-01T00:00:00.000Z').toISOString(),
})

Deletion and retention are idempotent. In their shared DeleteResult, runIds contains affected evalRunId values, runsDeleted counts distinct affected evaluation runs, and recordsDeleted counts records. A sources retention scope applies both age and count limits only to those sources; when it is the only field, all records in the selected sources are deleted.

For durable local storage, import the Node-only implementation separately:

import { FileEvalStore } from '@open-multi-agent/core/eval/file'
const fileStore = await FileEvalStore.open('./eval-results/history.ndjson', {
onDiagnostic(diagnostic) {
console.warn(diagnostic.code, diagnostic.message)
},
})
await fileStore.append(storedReport.records)
await fileStore.flush()
await fileStore.compact()
await fileStore.close()

FileEvalStore is a single-process reference implementation, not a production database or a cross-process coordination layer. It keeps an append-only, schema-versioned NDJSON mutation log and rebuilds its in-memory index on open. A committed batch is visible in full or not at all; a process or machine crash can lose at most the last, not-yet-durable batch. flush() is the explicit fsync boundary. Recovery truncates only an incomplete final line or batch and emits a diagnostic; complete corruption fails loudly.

Compaction writes <file>.compact.tmp, fsyncs it, atomically renames it over the target, and then fsyncs the parent directory where supported. A stale temp file never overrides an existing target. Use a database-backed EvalStore adapter when multiple processes, large data volumes, or server-side aggregation are required.

Stores preserve unknown fields within the supported schema major so future minor additions survive a round trip. A higher schemaVersion major is rejected rather than downgraded. There is intentionally no aggregation method on EvalStore: calculate trends from queried records in memory. Needing aggregation pushdown is a signal to introduce a database adapter, not to add file-specific concepts to the interface.

Persistence is fail-open for the evaluation run. If a sample batch cannot be stored, runEvalSet() still returns its complete records and aggregates and adds one payload-free entry to report.warnings for that sample.

Use the convenience targets when the system under evaluation is an OMA agent, team, or fixed plan:

import { Team, type AgentConfig, type PlanArtifact } from '@open-multi-agent/core'
import {
targetFromAgent,
targetFromPlan,
targetFromTeam,
} from '@open-multi-agent/core/eval'
declare const agent: AgentConfig
declare const team: Team
declare const plan: PlanArtifact
const agentTarget = targetFromAgent(agent, {
metadata: { prompt_version: 'v2' },
})
const teamTarget = targetFromTeam(team)
const planTarget = targetFromPlan(team, plan)
void agentTarget
void teamTarget
void planTarget

Agent and team targets convert non-string input with String(input) and use it as the prompt or goal. Plan targets replay the supplied PlanArtifact; the plan fixes the tasks and goal. These wrappers return the OMA result alongside the primary output, inject eval_case and one-based eval_repeat run metadata, and add available model/provider fingerprints. The runner uses the result identity for runRef and the result usage for report totals. When traceStore is provided, it also loads the matching StoredRun into ScorerContext.trace.

Record metadata merges in this order, with later values winning: case metadata, runEvalSet() metadata, then metadata echoed by a convenience target (including its configuration fingerprint).

File I/O is isolated in the Node-only @open-multi-agent/core/eval/file subpath. The root package and @open-multi-agent/core/eval do not import this entry point.

import {
loadEvalReport,
loadEvalSet,
loadGatePolicy,
writeEvalReport,
} from '@open-multi-agent/core/eval/file'
const setFromJson = await loadEvalSet('./evals/greetings.json')
const fileReport = await runEvalSet(setFromJson, target, { scorers: [exact] })
await writeEvalReport(fileReport, { format: 'json', path: './report.json' })
await writeEvalReport(fileReport, { format: 'markdown', path: './report.md' })
await writeEvalReport(fileReport, { format: 'junit', path: './report.junit.xml' })
const policy = await loadGatePolicy('./evals/gate.json')
const baseline = await loadEvalReport('./evals/baseline.json')

loadEvalSet() parses JSON, applies the same validation and deep freezing as defineEvalSet(), and includes the resolved file path plus the first schema issue in validation errors. writeEvalReport() creates parent directories as needed and supports:

  • json: the authoritative, pretty-printed EvalRunReport representation.
  • markdown: metadata, scorer and tag aggregates, failed samples, and totals for human review. Long failure reasons are truncated.
  • junit: one testcase per record. pass: false becomes <failure>; scorer_error and target_error become <error>; records without pass and without an error are successful testcases. XML names and messages are fully escaped.

loadGatePolicy() and loadEvalReport() validate their schema-versioned JSON contracts and report the resolved file path plus the first invalid field. Loaded objects are defensively copied and deeply frozen.

After building or installing the package, a no-network target can be evaluated from a shell or CI job:

Terminal window
oma eval run --set ./evals/greetings.json --target ./evals/target.mjs \
--report json --report junit --out ./eval-results \
--gate ./evals/gate.json --baseline ./evals/baseline.json \
--meta prompt_version=v2

The target module must default-export an EvalTarget function or { target, scorers? }. An optional --scorers module default-exports a Scorer[]; scorer names must be unique across both sources. The CLI dynamically imports and executes these user modules with the current process permissions, so they must be trusted.

Reports are written below <out>/<evalRunId>/; --out defaults to ./eval-results. --report is repeatable and defaults to JSON. --meta key=value is also repeatable and all values are strings. See the CLI reference for the complete argument and exit-code contract.

Without --gate, low scores and pass: false records do not change the exit code. With --gate, the stdout summary includes the verdict and its path, and the exact { pass, failures, warnings } object is written to <out>/<evalRunId>/verdict.json. A failed gate or every selected target failing exits 1. Usage/file/module errors exit 2. --baseline requires --gate.

evaluateGate() is pure logic exported from @open-multi-agent/core/eval:

import { evaluateGate } from '@open-multi-agent/core/eval'
const verdict = evaluateGate(report, {
schemaVersion: 1,
thresholds: [
// A rule scorer plus passRate=1 is a deterministic quality gate.
{ scorer: 'exact', metric: 'passRate', min: 1 },
{ scorer: 'relevancy', metric: 'avg', min: 0.8 },
{ scorer: 'relevancy', metric: 'p50', min: 0.85, tag: 'critical' },
],
maxScorerErrorRate: 0.1,
maxTargetErrorRate: 0,
baseline: {
maxRegression: 0.05,
perScorer: { exact: 0 },
},
}, baseline)
if (!verdict.pass) process.exitCode = 1

The equivalent JSON policy is:

{
"schemaVersion": 1,
"thresholds": [
{ "scorer": "exact", "metric": "passRate", "min": 1 },
{ "scorer": "relevancy", "metric": "avg", "min": 0.8 },
{ "scorer": "relevancy", "metric": "p50", "min": 0.85, "tag": "critical" }
],
"maxScorerErrorRate": 0.1,
"maxTargetErrorRate": 0,
"baseline": {
"maxRegression": 0.05,
"perScorer": { "exact": 0 }
}
}

Thresholds support avg, p50, p95, min, and passRate, with optional tag scoping and inclusive min/max boundaries. A missing scorer, tag, or passRate source is a configuration failure rather than a silent pass. The health defaults fail when scorer errors exceed 10% of scored plus scorer-error records, or when any selected target fails.

Every verdict contains only pass, failures, and warnings. A failure has a stable kind, optional scorer/metric/tag coordinates, the observed actual, the configured limit, and a human-readable message. For failures about availability rather than a measured score, missing_scorer uses actual: 0 and limit: 1, while baseline_mismatch uses actual: 1 and limit: 0.

A baseline is an ordinary JSON EvalRunReport, not a second file format. The recommended workflow is:

  1. Run the accepted target with --report json and copy its report.json to a reviewed location such as evals/baseline.json.
  2. Commit that report together with its versioned EvalSet and gate policy.
  3. In CI, compare the candidate report with --baseline.
  4. Update the baseline only after intentionally reviewing and accepting the behavior change; the CLI never updates it automatically.

Set name or version mismatches fail by default. Set baseline.allowSetMismatch to true only when a warning plus skipped regression checks is intended. When a scorer version differs, OMA warns and skips that scorer’s regression checks because a changed judge prompt or model does not produce a comparable score. Threshold and health checks still run. If baseline rules are configured but no baseline report is supplied, OMA warns and skips regression checks.

Use oma eval gate when report generation and quality enforcement are separate CI stages. It prints the exact verdict JSON to stdout:

Terminal window
oma eval gate --report ./candidate/report.json --gate ./evals/gate.json \
--baseline ./evals/baseline.json

A GitHub Actions job can preserve both machine-readable reports while letting the gate control the step status:

- name: Run deterministic evaluation gate
run: |
oma eval run --set ./evals/set.json --target ./evals/target.mjs \
--gate ./evals/gate.json --baseline ./evals/baseline.json \
--report json --report junit --out ./eval-results || exit 1
- name: Upload evaluation JUnit report
if: always()
uses: actions/upload-artifact@v4
with:
name: evaluation-junit
path: eval-results/**/report.junit.xml

MemoryExtractionSample and MemoryRetrievalSample are experimental input shapes for future memory scorers; this release adds no memory runtime and no automatic memory writer. The following metrics can be implemented with existing rule or judge scorers:

StageMetricDefinition
ExtractionYieldValid extracted records relative to conversation, token, latency, or monetary cost. Report raw counts beside any ratio.
ExtractionDuplicate and conflict rateShare of records that repeat, contradict, or add no durable information. Rule checks can catch exact duplicates; semantic conflict needs a versioned judge.
ExtractionStaleness annotation rateShare of time-sensitive records carrying enough provenance or expiry information to identify staleness risk.
ExtractionScope leakagePrivate content written into team scope. This is a safety gate: any non-zero leakage fails.
ExtractionCost and reasonsExtraction latency and tokens, plus the distribution of skipped and merged reasons.
RetrievalRelevanceJudge score between the query and retrieved records. Version the rubric and judge configuration.
RetrievalOmissionAvailable positive records that should have been returned but were not.
RetrievalPollutionRun the same case with and without retrieved memory; a lower primary score after injection is harmful pollution.
RetrievalAdded costExtra tokens and latency caused by retrieval and prompt injection.

Automatic extraction or consolidation should not be enabled by default until it passes both a versioned offline EvalSet gate and online sampling. Scope leakage is always a hard safety gate, independent of average quality.

EvalSet cases may contain private user data. storePayloads therefore defaults to 'none', so records contain scores, reasons, metadata, and run references but no input/output snapshots. 'redacted' serializes each payload field, caps it at 8 KiB, and applies OMA’s existing secret redaction. 'full' keeps the serialized text without redaction but still applies the 8 KiB cap; opt into it only for data you are prepared to retain. A model-based judge necessarily sends the evaluated output to the configured judge model regardless of record payload storage.

OMA’s current provider contract has no cross-provider seed parameter or LLM response recording. Adding a seed to EvalSet would therefore promise determinism the framework cannot provide. Use repeats to sample nondeterministic behavior and compare the aggregate statistics. targetFromPlan() fixes the orchestration plan, but model responses can still vary.

import { z } from 'zod'
import { createJudgeScorer } from '@open-multi-agent/core/eval'
const relevancy = createJudgeScorer({
name: 'relevancy',
version: 'prompt-v1',
judges: [
{ name: 'judge-a', model: 'claude-sonnet-4-6', provider: 'anthropic' },
{ name: 'judge-b', model: 'gpt-5', provider: 'openai' },
],
quorum: 2,
timeoutMs: 30_000,
verdictSchema: z.object({
score: z.number().min(0).max(1),
pass: z.boolean(),
reason: z.string(),
}),
})
const result = await relevancy.score(context)
console.log(result.score, result.pass)

Judge scores are averaged. When the verdict schema returns a boolean pass, the scorer returns pass: true after the configured quorum is reached. The default verdict schema contains only score and reason, so the default result leaves pass undefined.

result.details.judges, result.details.models, and result.details.scores are parallel arrays: values at the same index describe one judge. This flat representation remains compatible with trace attribute values while preserving model-drift evidence. Bump the scorer version whenever judge models, configuration, or prompts change.

No. It means quality was not measured. OMA records scorer_error, excludes it from score denominators, and lets gate health limits decide whether the evaluation infrastructure is reliable enough.

Why did a baseline scorer comparison produce a warning?

Section titled “Why did a baseline scorer comparison produce a warning?”

Candidate and baseline scorer versions differ, or one side omitted the scorer. OMA still applies absolute thresholds and health checks, but skips an invalid apples-to-oranges regression comparison. Review the scorer change and create a new accepted baseline intentionally.

Can evaluation delay or fail the business response?

Section titled “Can evaluation delay or fail the business response?”

Offline evaluation is a separate call. Online evaluation performs only a synchronous sampling and bounded-queue decision after the run settles; scoring and persistence are best-effort and isolated. Call forceFlush() when the host must wait for accepted samples before exit.

Does targetFromPlan() make an LLM run deterministic?

Section titled “Does targetFromPlan() make an LLM run deterministic?”

It fixes the task graph and avoids another coordinator decomposition. Model responses can still vary because OMA has no cross-provider seed contract. Use repeats and compare distributions.

Run examples/patterns/eval-offline-regression.ts for a no-key two-target gate or examples/patterns/eval-online-sampling.ts for FileEvalStore lifecycle.