Experiment Runner¶
Run finite evaluation cases and repeated trials with bounded concurrency, delayed retries, per-trial provider scopes, run-level measurement, structured quality policies, and deterministic JSON—without coupling the experiment to a specific agent loop, test framework, dataset host, or observability provider.
The runner lives in NexusLabs.Foundry.Evaluation.Experiments.
Quick Start¶
using Microsoft.Extensions.AI.Evaluation;
using NexusLabs.Foundry.Evaluation;
using NexusLabs.Foundry.Evaluation.Experiments;
var thresholds = new EvaluationThresholdEvaluator()
.RequireNumericMin("completion_rate", 0.95);
var definition = new ExperimentDefinition<MyCase, MyOutput>
{
Name = "support-regression",
CaseSource = new LocalExperimentCaseSource<MyCase>(
"local-cases",
[
new ExperimentCase<MyCase>
{
Id = "refund",
Value = new MyCase("Refund an eligible order"),
TrialCount = 30,
Tags = ["billing"],
},
]),
Task = async (context, cancellationToken) =>
await RunAgentAsync(context.Case.Value, cancellationToken),
ItemEvaluator = (context, cancellationToken) =>
EvaluateAsync(
context.Case.Value,
context.Output,
cancellationToken),
RunEvaluators =
[
new ExperimentRunEvaluator<MyCase, MyOutput>(
"aggregate",
(context, _) =>
{
var completionRate =
(double)context.Items.Count(x => x.Status == ExperimentItemStatus.Succeeded)
/ context.Items.Count;
return ValueTask.FromResult(new EvaluationResult(
new NumericMetric("completion_rate", completionRate)));
}),
],
Policies =
[
new ExperimentRunEvaluationThresholdPolicy<MyCase, MyOutput>(
"completion",
"aggregate",
thresholds),
new ExperimentBinarySuccessPolicy<MyCase, MyOutput>(
"binary-success",
"passed",
requiredSuccessRate: 0.9,
minimumSampleCount: 30,
confidenceLevel: 0.95),
],
};
await using var sharedLimiter = new ExperimentConcurrencyLimiter(8);
ExperimentRunOutcome<MyCase, MyOutput> outcome =
await new ExperimentRunner().RunAsync(
definition,
new ExperimentRunOptions
{
RunId = "commit-abc123",
MaxConcurrency = 4,
AttemptTimeout = TimeSpan.FromMinutes(2),
RetryPolicy = new ExperimentRetryPolicy(
maxAttempts: 3,
retryOn: ExperimentRetryableOutcome.ExecutionFailure
| ExperimentRetryableOutcome.Timeout,
delay: TimeSpan.FromSeconds(1)),
SharedLimiter = sharedLimiter,
},
cancellationToken);
ExperimentRunResult<MyCase, MyOutput> result = outcome.Result;
Every case is materialized and validated before execution. Cases expand in source order, then by ascending one-based trial index. Items return in that stable order regardless of completion or retry order.
The provider-neutral execution APIs use explicit overloads rather than optional parameters:
IExperimentCaseSource<TCase>.LoadAsync()delegates toLoadAsync(CancellationToken.None);IExperimentRunner.RunAsync(definition, options)delegates to the overload that acceptsCancellationToken;ExperimentRunner()usesTimeProvider.System, whileExperimentRunner(TimeProvider?)also falls back toTimeProvider.Systemfor an explicit null value.
The built-in policies also use explicit constructor matrices. Neither policy exposes optional parameters:
| Policy | Explicit constructor suffix | Resulting configuration |
|---|---|---|
ExperimentBinarySuccessPolicy<TCase,TOutput> |
Statistical arguments only | Required; unknown samples are inconclusive. |
ExperimentBinarySuccessPolicy<TCase,TOutput> |
bool isRequired, ExperimentUnknownSampleTreatment |
Both values specified explicitly. |
ExperimentRunEvaluationThresholdPolicy<TCase,TOutput> |
Threshold arguments only | Required; missing metrics are inconclusive. |
ExperimentRunEvaluationThresholdPolicy<TCase,TOutput> |
bool isRequired, EvaluationMissingMetricBehavior |
Both values specified explicitly. |
Cases, Trials, and Attempts¶
| Term | Meaning |
|---|---|
| Case | One logical caller-owned input with a required unique ID. |
| Trial | One statistically independent scheduled execution of a case. |
| Item | The runner's work unit for one case and one trial index. |
| Attempt | One operational execution try for an item. Retries add attempts, never trials. |
ExperimentCase<TCase>.TrialCount controls statistical replication. TCase remains caller-owned,
so expected outputs, metadata, and provider references can live in one domain-specific value.
ExperimentTaskContext<TCase> supplies the run ID, stable sequence, materialized case, one-based
trial index, one-based attempt number, and exact-type adapter features exposed by item scopes.
Validation Before Execution¶
The runner rejects invalid static configuration before starting any item:
- blank experiment, run, source, case, run-evaluator, policy, or item-scope names;
- duplicate case IDs, run-evaluator names, policy names, or item-scope names using ordinal comparison;
- non-positive trial counts or
MaxConcurrency; - non-positive retry-policy attempt limits;
- invalid attempt or item-scope cleanup timeouts;
- an expanded item count that cannot be represented safely.
Source-loading and validation failures throw to the caller. Per-item execution, item-evaluation, run-evaluation, and policy failures are represented structurally after execution begins.
Ready and Delayed Scheduling¶
The runner uses:
- one bounded ready channel containing item state;
min(MaxConcurrency, itemCount)long-lived worker tasks;- one timed priority queue for delayed retries;
- one dispatcher that promotes due retries back into the ready channel;
- one indexed result slot per statistical trial.
Pending work remains data rather than one task per item. A delayed retry is not attached to a sleeping worker: its item state sits in the timed queue until its ready time. This lets workers run other ready items while retries are parked.
ExperimentRunResult.WorkerCount reports the local worker count. MaxConcurrency bounds active
execution attempts, not delayed retries, item evaluation, or total queued work.
Explicit Retries¶
No retry policy means exactly one attempt, preserving the default behavior.
ExperimentRetryPolicy requires:
- a maximum total attempt count, including the initial attempt;
- explicit eligible outcomes;
- either a fixed delay or a caller-supplied delay function.
Eligible outcomes are execution failure, timeout, and task-originated cancellation. Caller cancellation is never offered to the retry policy. Item evaluation, run evaluation, and quality policy failures never replay task execution.
Each ExperimentAttemptResult records:
- attempt number, status, start time, and duration;
- the structured execution failure, when present;
DelayBeforeNextAttemptwhen another attempt was scheduled.
DelayBeforeNextAttempt = TimeSpan.Zero means an immediate re-enqueue. null means no attempt
followed. A failure selected for retry has IsRetryable = true; an exhausted or unselected terminal
failure does not claim another retry.
Custom IExperimentRetryPolicy implementations remain bounded by MaxAttempts. Invalid decisions
or policy exceptions become an item-level RetryPolicyFailed failure rather than disappearing or
failing unrelated items. Hidden random jitter is not added. A jittered delay function must use
caller-controlled deterministic state when reproducibility matters.
Shared Concurrency Limiter¶
MaxConcurrency is local to one run. Set ExperimentRunOptions.SharedLimiter when simultaneous
runs must share a second admission boundary:
await using var limiter = new ExperimentConcurrencyLimiter(12);
var first = runner.RunAsync(
firstDefinition,
new ExperimentRunOptions
{
RunId = "first",
MaxConcurrency = 8,
SharedLimiter = limiter,
},
cancellationToken);
var second = runner.RunAsync(
secondDefinition,
new ExperimentRunOptions
{
RunId = "second",
MaxConcurrency = 8,
SharedLimiter = limiter,
},
cancellationToken);
The runner:
- acquires one
IExperimentConcurrencyLimiterlease before first-attempt scope entry and reuses that lease for the attempt; - acquires one lease around each later attempt;
- awaits acquisition with the caller token;
- disposes the lease when scope entry or the attempt completes;
- releases the lease before computing or waiting through a retry delay;
- never disposes the caller-owned limiter.
The built-in ExperimentConcurrencyLimiter is semaphore-backed and suitable for DI registration.
Alternate implementations can adapt provider quotas or resource-aware admission logic.
Per-Trial Item Scopes¶
ExperimentDefinition<TCase,TOutput>.ItemScopes adds provider lifecycle without giving providers
ownership of scheduling, retries, evaluation, or quality policy.
For every statistical trial, the runner:
- enters each
IExperimentItemScopeProvider<TCase,TOutput>once in registration order; - snapshots exact-type scope features into
ExperimentItemFeatureCollection; - activates entered scopes in registration order around every task attempt;
- deactivates them in reverse order before a retry delay;
- reactivates them around the single item evaluator after terminal execution success;
- sends every terminal quality status to
CompleteAsyncin registration order; - disposes scopes in reverse order and records completion or disposal failure structurally.
CompleteAsync returns an ExperimentItemPublicationOperationResult, not the canonical
provider result. Implementations select Succeeded(correlations), NotAttempted(correlations), or
Failed(correlations, failure) and do not return provider identity. The factories snapshot the
correlation list. The runner validates and snapshots correlation/failure content, then stamps the
registered provider Name and IsRequired into the unchanged canonical
ExperimentItemPublicationResult.
The scope object remains alive across delayed retries, but no activation handle, worker slot, or
shared-limiter lease remains held during the delay. EnterAsync receives only the caller token and
runs outside AttemptTimeout; it executes after shared admission so provider setup participates in
the caller-owned concurrency boundary.
Tasks and evaluators retrieve adapter features by exact registered type:
A feature object must remain valid across repeated activation/deactivation cycles. Scope implementations snapshot their feature dictionary during entry, so later mutation does not change the trial context. Registering the same exact feature type from a later scope fails that later scope deterministically; the first registration remains available.
ExperimentItemScopeFailureMode.BestEffort records entry or activation failure as
ExperimentPublicationOperationStatus.Failed while quality processing continues.
ExecutionPrerequisite stops the next task attempt and produces PrerequisiteFailed with no
fabricated attempt. Prerequisite failures are unknown statistical samples by default; the binary
policy excludes them unless ExperimentUnknownSampleTreatment.CountAsFailure is selected.
IsRequired does not change item quality. The runner copies it from the registered provider onto
each canonical ExperimentItemPublicationResult for aggregate publication health in the
result-sink phase. Provider correlations use structured namespace/name/value triples and are copied
into both the provider result and the item aggregate. Malformed correlations or publication
failures are rejected during normalization and become structured scope publication failures.
Normal scope teardown is CompleteAsync then DisposeAsync. Caller cancellation invokes
AbortAsync then DisposeAsync in reverse scope order for every entered scope whose completion has
not started. Once CompleteAsync begins, that method owns cancellation-safe termination and the
runner does not also invoke AbortAsync. Completion, abort, and disposal run without ambient
activation and must use scope-owned state. ExperimentRunOptions.ItemScopeCleanupTimeout bounds
cancellation cleanup and terminal disposal; it defaults to 30 seconds.
MEAI Reporting Adapter¶
Install NexusLabs.Foundry.Evaluation.Reporting when an experiment should use MEAI
Reporting response caching, ScenarioRun persistence, and the official report writers. The
provider-neutral Evaluation package does not reference Reporting.
Create a run-specific ReportingConfiguration, then attach the coordinated scope/evaluator pair
with WithMeaiReporting(...):
using Microsoft.Extensions.AI.Evaluation.Reporting.Storage;
using NexusLabs.Foundry.Evaluation.Reporting;
const string runId = "commit-abc123";
var reportingConfiguration = DiskBasedReportingConfiguration.Create(
storageRootPath,
evaluators,
new ChatConfiguration(chatClient),
enableResponseCaching: true,
executionName: runId);
var definition = new ExperimentDefinition<MyCase, MyOutput>
{
Name = "support-regression",
CaseSource = caseSource,
Task = async (context, cancellationToken) =>
{
var reporting = context.Features
.GetRequired<MeaiReportingExperimentItem>();
ChatResponse response = await reporting.ChatConfiguration!.ChatClient
.GetResponseAsync(
context.Case.Value.Messages,
cancellationToken: cancellationToken);
return new MyOutput(context.Case.Value.Messages, response);
},
}.WithMeaiReporting(
reportingConfiguration,
context => new EvaluationInputs(
context.Output.Messages,
context.Output.Response),
new MeaiReportingExperimentOptions<MyCase, MyOutput>
{
ResponseReuseMode =
MeaiReportingResponseReuseMode.CaseAndTrialReplay,
IsRequired = true,
});
The configuration's ExecutionName must equal ExperimentRunOptions.RunId. The adapter maps
ExperimentCase.Id to ScenarioName and the one-based trial index to IterationName. When using
the disk result store, those values must also be valid path segments.
WithMeaiReporting(...) rejects a definition that already has an item evaluator. Configure every
MEAI evaluator on ReportingConfiguration; MEAI's CompositeEvaluator remains the single
evaluation producer.
| Response reuse mode | Configuration and behavior |
|---|---|
CaseAndTrialReplay |
Requires a response cache. Matching request, model, scenario, and trial cache keys can replay across separate Foundry runs. |
FreshPerRun |
Requires a response cache and adds the Foundry run ID to the scenario cache keys. Retries still replay within the same run. |
Disabled |
Requires a configuration with no response-cache provider. Every attempt reaches the underlying chat client. |
The task must use MeaiReportingExperimentItem.ChatConfiguration.ChatClient for primary model
responses to participate in MEAI caching. A cache hit still requires the complete MEAI key to match;
case and trial identity alone do not override changed messages, options, model metadata, or streaming
mode.
One ScenarioRun remains alive across all operational retries. The paired item evaluator calls
ScenarioRun.EvaluateAsync(...) once after terminal execution success, and normal scope disposal
persists the completed result. Store exceptions become ItemScopeFailed publication failures and do
not replay execution or alter item quality. Caller cancellation aborts incomplete scenarios without
persisting them. MEAI does not accept a cancellation token on ScenarioRun.DisposeAsync; the
runner's cleanup timeout can therefore report a disposal failure while a provider store operation
finishes later.
Foundry does not render MEAI reports or fabricate aggregate ScenarioRun entries. Read the configured
IEvaluationResultStore and use MEAI's HtmlReportWriter, JsonReportWriter, or dotnet aieval
tooling.
MEAI Reporting and Langfuse can compose on the same definition. Add the Langfuse item scope and
result sink, then call WithMeaiReporting(...); Reporting produces the canonical item
EvaluationResult, and the Langfuse sink projects those same metrics without re-evaluating. A
consumer-defined sink can receive the same canonical run and publish domain artifacts independently.
If execution fails before evaluation, the canonical item retains the failure, the Langfuse trial
scope can still retain its trace and dataset link, and Reporting remains NotAttempted because no
completed ScenarioRun exists to persist.
Final Result Sinks¶
ExperimentDefinition<TCase,TOutput>.Sinks publishes the completed canonical quality result without
giving providers ownership of execution or quality policy.
Each named IExperimentResultSink<TCase,TOutput>:
- receives the same
ExperimentRunResult<TCase,TOutput>reference after run evaluation and policy reduction finish; - runs sequentially in registration order;
- declares whether publication is required;
- returns
ExperimentSinkPublicationOperationResult.Succeeded(),NotAttempted(), orFailed(failure)without repeating its identity; - owns provider retry only when its operation is provably idempotent.
The runner validates the operation result and stamps the registered sink Name and IsRequired
onto the unchanged canonical ExperimentSinkResult. A thrown exception, null, or malformed sink
operation becomes a structured ResultSinkFailed result and does not suppress later sinks.
Caller-token cancellation propagates exactly, skips later sinks, and produces no outcome.
IExperimentRunner.RunAsync returns ExperimentRunOutcome<TCase,TOutput>:
| Publication status | Meaning |
|---|---|
NotRequested |
No item scope or final sink attempted publication. |
Succeeded |
Every attempted publication succeeded. |
PartiallyFailed |
At least one optional publication failed and no required publication failed. |
Failed |
At least one required publication failed. |
The aggregate includes both Result.Items[*].Publications and final SinkResults.
outcome.PublicationStatus never changes outcome.Result.Decision.
Foundry-owned collections are read-only snapshots. Caller-owned case/output values and mutable MEAI evaluation objects cannot be deeply frozen; sinks must treat the entire result as read-only.
Cancellation and Timeouts¶
Caller cancellation:
- stops ready-channel admission, limiter waits, active tasks, retry delays, item evaluation, run evaluation, policies, and final result sinks;
- wins over simultaneous timeout or execution failure;
- is rethrown from
RunAsyncwith the original caller token; - aborts every entered incomplete item scope and disposes scopes in reverse order within the configured cleanup timeout;
- produces no completed run outcome.
AttemptTimeout is cooperative and restarts for every attempt. The task receives a linked
caller/deadline token. If a task ignores deadline cancellation, the runner continues awaiting it so
worker and shared-limiter accounting remain truthful. An output returned after deadline expiration
is discarded and classified TimedOut.
Item Evaluation¶
The optional ExperimentItemEvaluator<TCase,TOutput> runs once after terminal execution success.
It receives the successful output and complete attempt history.
Evaluator failure:
- preserves the successful task output;
- marks the item
EvaluationFailed; - does not invoke the retry policy;
- never replays execution.
The mutable MEAI EvaluationResult remains available on the item but is excluded from canonical
JSON. Foundry immediately freezes its metrics, diagnostics, interpretations, and metadata into
ordered ExperimentMetricSnapshot values.
Run Evaluators¶
Run evaluators measure; policies decide.
Each named IExperimentRunEvaluator<TCase,TOutput> receives every item in stable sequence order,
including execution failures, timeouts, task-originated cancellations, and evaluation failures.
Evaluators run sequentially in registration order.
A successful evaluator retains its MEAI EvaluationResult and frozen metrics. An exception or
normalization failure produces ExperimentRunEvaluationStatus.Failed with a structured
RunEvaluationFailed failure, then later evaluators still run.
Deterministic Policies¶
ExperimentRunEvaluationThresholdPolicy<TCase,TOutput> applies an
EvaluationThresholdEvaluator to one named successful run evaluation.
The threshold evaluator supports:
- required or optional numeric minima;
- required or optional numeric maxima;
- required or optional boolean equality;
- structured per-threshold outcomes;
- missing/invalid required evidence as
Inconclusiveby default; - explicit pessimistic treatment as
Failed.
Optional thresholds permit a conditionally emitted metric to be absent. If an optional metric is present but invalid or violates its bound, it still affects the decision.
Binary Statistical Policy¶
ExperimentBinarySuccessPolicy<TCase,TOutput> evaluates a required boolean item metric with an
uncorrected Wilson score interval.
The caller explicitly supplies:
- required success rate;
- minimum effective sample count;
- one-sided confidence level;
- unknown-sample treatment.
Default item treatment is:
| Item outcome | Treatment |
|---|---|
Succeeded with a valid boolean metric |
Denominator success or failure. |
ExecutionFailed |
Denominator failure. |
TimedOut |
Denominator failure. |
Task-originated Canceled |
Denominator failure. |
EvaluationFailed, PrerequisiteFailed, missing/invalid metric, or failed metric diagnostics |
Exclusion that forces Inconclusive. |
ExperimentUnknownSampleTreatment.CountAsFailure is the explicit pessimistic alternative.
The decision rule is:
Passed one-sided lower Wilson bound >= required success rate
Failed one-sided upper Wilson bound < required success rate
Inconclusive otherwise, below minimum sample count, or unknown evidence remains
Evidence reports trial count, operational attempt count, effective sample count, successes,
failures, execution failures, exclusions, status counts, estimate, bounds, confidence level,
threshold, and interval method. Each bound uses the configured one-sided confidence level. Taken
together, the lower and upper bounds have two-sided coverage 2 × confidenceLevel - 1.
Retries only increase AttemptCount; they never increase TotalTrialCount or the statistical
denominator.
Overall Decision¶
Required policies reduce deterministically:
- Any required
Failedpolicy makes the runFailed. - Otherwise, any required
Inconclusivepolicy makes the runInconclusive. - Otherwise, every required policy passed and the run is
Passed. - No required policies produces
NotEvaluated.
Optional policies remain in PolicyResults but do not affect the run decision. A policy exception
is represented as Inconclusive with a structured PolicyFailed failure; it is not fabricated into
a quality failure.
EvaluationQualityGate Reuse¶
EvaluationQualityGate and experiment threshold policies share
EvaluationThresholdEvaluator. Use Evaluate(...) for structured non-throwing decisions and
Assert(...) as the CI-oriented throwing adapter.
Required missing or invalid metrics no longer pass silently:
var gate = new EvaluationQualityGate()
.RequireBoolean("required_metric", expected: true)
.OptionalBoolean("conditional_metric", expected: true);
EvaluationThresholdResult result = gate.Evaluate(evaluation);
gate.Assert(evaluation);
Assert(...) throws for Failed or Inconclusive; an optional missing metric does not block a
passing decision.
JSON Artifact¶
ExperimentJsonArtifactWriter writes a schema-version-4 outcome envelope with fixed Foundry-owned
property ordering:
- the unchanged schema-version-3 canonical quality result;
- source/trial item order;
- complete attempt history and delay-before-next-attempt values;
- normalized item metrics;
- namespaced item correlations and ordered item-scope publication results;
- ordered run-evaluation results;
- ordered policy results and structured evidence;
- the overall run decision;
- aggregate publication status and ordered final sink results;
- structured failures without raw exceptions or stack traces.
Publication operation results are normalization inputs only and are not serialized. Equivalent canonical outcomes retain the existing schema-version-3 result and schema-version-4 envelope property names, ordering, and JSON bytes.
Constructors and write operations also use explicit overloads. Reflection-based serialization
offers tokenless, cancellable, default-options, and explicit-JsonSerializerOptions forms. The AOT
stream path offers tokenless and cancellable JsonTypeInfo<T> forms. Tokenless overloads use
CancellationToken.None; all overloads route through one implementation per serialization family
so identical inputs produce identical JSON bytes.
For Native AOT, pass caller-generated metadata:
using System.Text.Json.Serialization;
[JsonSerializable(typeof(MyCase))]
[JsonSerializable(typeof(MyOutput))]
internal partial class MyJsonContext : JsonSerializerContext;
await using var stream = File.Create(path);
await new ExperimentJsonArtifactWriter().WriteAsync(
stream,
outcome,
MyJsonContext.Default.MyCase,
MyJsonContext.Default.MyOutput,
cancellationToken);
The JsonTypeInfo<TCase> / JsonTypeInfo<TOutput> overload is the dependable trimmed and Native
AOT path. Caller-owned payload schemas remain caller responsibility. Determinism applies to the
Foundry envelope; it does not claim RFC 8785 cryptographic canonicalization.
Provider Convergence¶
The ADR-0003 workstream is complete:
LangfuseDatasetCaseSource<TCase>provides validated paginated dataset reads for latest or timestamped hosted sources.- The item-scope adapter creates one reactivatable Langfuse trace and at most one hosted dataset-run-item link per statistical trial.
- The result sink projects canonical item and run measurements plus the optional canonical decision.
- The isolated MEAI Reporting adapter provides the second-provider proof.
Further scheduler, retry, evaluator, statistical-policy, or provider expansion requires a separate design and issue rather than extending this workstream implicitly.
Runnable Example¶
Run the credential-free Phase 3B example:
It uses seeded stochastic trials, a scripted retry, mixed terminal outcomes, a shared limiter, run evaluation, deterministic and Wilson-bound policies, a credential-free final sink, and an AOT-safe schema-v4 outcome artifact under the system temporary directory.
Run the credential-free MEAI Reporting adapter example:
It uses the scenario-wrapped chat client to demonstrate case/trial replay across runs,
fresh-per-run cache isolation, one persisted ScenarioRun per trial, and an official MEAI JSON
report under the system temporary directory.