EvaluatedApplications.EvalApp 3.1.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package EvaluatedApplications.EvalApp --version 3.1.0
                    
NuGet\Install-Package EvaluatedApplications.EvalApp -Version 3.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="EvaluatedApplications.EvalApp" Version="3.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EvaluatedApplications.EvalApp" Version="3.1.0" />
                    
Directory.Packages.props
<PackageReference Include="EvaluatedApplications.EvalApp" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add EvaluatedApplications.EvalApp --version 3.1.0
                    
#r "nuget: EvaluatedApplications.EvalApp, 3.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package EvaluatedApplications.EvalApp@3.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=EvaluatedApplications.EvalApp&version=3.1.0
                    
Install as a Cake Addin
#tool nuget:?package=EvaluatedApplications.EvalApp&version=3.1.0
                    
Install as a Cake Tool

EvalApp

Async pipeline runtime for .NET. You describe a process as a typed data record and a sequence of steps; EvalApp compiles that description into an immutable, reusable execution path, gates every parallel operation behind a declared resource budget (CPU/IO/Network/Database/custom), and — when you opt in — an adaptive or Bayesian tuner discovers the right amount of parallelism for your hardware while it runs. Zero dependencies, multi-targets net8.0/net10.0, ships binary-only (no source, no XML docs — IP protection, not obfuscation). This page is the only documentation that ships with the package — there is no source, no docs site, and nothing below is license-gated. Author against the root EvalApp namespace only — using EvalApp; brings in the entire public surface (fluent API, step base classes, context base classes, licensing, the extension seams). Everything else in the assembly is the internal engine and is not part of the supported API.

Every code block below that starts with a using directive and a full Eval.App(...)...Build(); chain is a complete, standalone program — pasted verbatim into a throwaway console project, dotnet build+dotnet run, with real output, before this page shipped. A handful of short bullet-point fragments (e.g. the plain AddStep overload list, a bare .If(...)/.AddSubTaskFor(...) line) are illustrative continuations of the surrounding example, not complete programs on their own.

Table of contents

  1. Quick start
  2. Core concepts: App → Domain → Task → Pipeline
  3. Step kinds
  4. Adding steps, fallback, and conditional branches
  5. Parallel groups and custom merge strategies
  6. Sub-tasks and ForEach, ChunkedWork
  7. Cross-pipeline bridges and streaming Materialize
  8. Resource gating: ResourceKind, Tunable, Gate
  9. Pressure signals and window budgets
  10. Sagas: compensation on failure
  11. Middleware
  12. Dependency injection (step factories)
  13. Context-aware steps
  14. Licensing
  15. Tuning modes (adaptive / Bayesian / custom settings)
  16. Inspecting a pipeline
  17. Anti-patterns to avoid
  18. Full public surface reference
  19. Migrating from 1.x

1. Quick start <a name="quick-start"></a>

dotnet add package EvaluatedApplications.EvalApp
using EvalApp;

Eval.App("Orders")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Validate", d =>
            {
                if (d.Total <= 0) throw new InvalidOperationException("Total must be positive");
                return d with { Status = "Validated" };
            })
            .AddStep("ApplyDiscount", d => d with { Total = d.Total * 0.9m })
            .AddStep("Complete", d => d with { Status = "Complete" })
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();   // no license key needed: nothing is license-gated today; runs at full concurrency

// Build once (e.g. at startup, register `pipeline` as a singleton). Run many times:
var result = await pipeline.RunAsync(new OrderData("Alice", 99.99m));

switch (result)
{
    case PipelineResult<OrderData>.Success success:
        Console.WriteLine($"{success.Data.Status}: {success.Data.Total}");
        break;
    case PipelineResult<OrderData>.Failure failure:
        Console.WriteLine($"Failed: {failure.Exception.Message}");
        break;
    case PipelineResult<OrderData>.Skipped skipped:
        Console.WriteLine($"Skipped: {skipped.Reason}");
        break;
}

// Top-level statements must precede type declarations in a Program.cs, so the record goes last:
public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

Read the fluent chain top to bottom and it reads like the process: define an app, define a domain, define a task, add steps, run it, build it. Run(out ICompiledPipeline<T> pipeline) hands you the compiled, reusable pipeline; .Build(licenseKey?) then finalizes the whole app. Rebuild once at startup — rebuilding per request throws away all accumulated tuner state (see Anti-patterns).


2. Core concepts <a name="core-concepts"></a>

Type One-line purpose
Eval.App(name = "Pipeline") The single entry point. Returns IAppBuilder.
IAppBuilder Top-level app configuration: resources, tuning, context, licensing.
IDomainBuilder A named domain; defines one or more tasks (and nested domains).
IEmptyTaskBuilder<T> A task before its first step — Run() is not available yet.
ITaskBuilder<T> The same task after ≥1 step — now Run()/Run(out pipeline) are available.
ICompiledPipeline<T> Opaque, immutable, reusable compiled pipeline: Name + RunAsync(data, ct).
PipelineResult<T> Discriminated union: Success(Data) / Failure(Data, Exception, Message?) / Skipped(Data, Reason), plus GetData()/IsSuccess/IsFailure/IsSkipped.
using EvalApp;

Eval.App("Orders")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Noop", d => d)
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new OrderData("Quin", 1m));
Console.WriteLine(result.IsSuccess);          // bool, no pattern-match needed
Console.WriteLine(result.GetData().Total);    // unwraps regardless of outcome

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

Invariant: Build() finalizes the whole app; ICompiledPipeline<T> is immutable and reusable — build once, call RunAsync many times from multiple threads safely.

A domain can nest another domain (IDomainBuilder.DefineDomain(name, domainContext?)), and an app can define more than one top-level domain — each is an independent namespace of tasks sharing the same app-level resources/tuning/context.


3. Step kinds <a name="step-kinds"></a>

Type Purpose Added via
IStep<T> The raw interface: ValueTask<T> ExecuteAsync(T data, CancellationToken ct = default). any AddStep overload taking IStep<T>
PureStep<T> Synchronous, side-effect-free transform. Override T Execute(T data). AddStep(name, PureStep<T>) or AddStep<TStep>()
AsyncStep<T> Asynchronous transform (I/O-bound work). Override ExecuteAsync. AddStep(name, AsyncStep<T>) or AddStep<TStep>()
SideEffectStep<T> A step whose job is a side effect (DB write, event publish, email). Declares virtual ResourceKind? ResourceKind. AddStep<TStep>()/AddStep<TStep>(name, factory) only — see gotcha below
CrossDomainStep<T,TShared> A SideEffectStep<T> that also carries a Shared object (service/bus) injected at construction. AddStep<TStep>(name, factory) (no new() needed) or AddStep<TStep>() if it has a parameterless ctor
using EvalApp;

// Top-level statements must come before ALL type declarations in a Program.cs file — the
// Eval.App(...) call goes first, every supporting type (including OrderData) goes after it.
Eval.App("StepKinds")
    .WithResource(ResourceKind.Network)   // REQUIRED: see gotcha below
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Tax", new ApplyTaxStep())             // PureStep<T> instance
            .AddStep("Fetch", new FetchTotalStep())          // AsyncStep<T> instance
            .AddStep<SendConfirmationStep>("SendConfirmation")  // SideEffectStep<T>: AddStep<TStep>() only
            .AddStep<PublishEventStep>("Publish", () => new PublishEventStep(new InMemoryBus()))  // factory overload: real injected dependency, no parameterless ctor needed
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New", decimal Tax = 0m);

public sealed class ApplyTaxStep : PureStep<OrderData>
{
    public override OrderData Execute(OrderData data) => data with { Tax = data.Total * 0.08m };
}

public sealed class FetchTotalStep : AsyncStep<OrderData>
{
    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        await Task.Yield();                       // real code: await the actual I/O here
        return data with { Total = data.Total + 1m };
    }
}

public sealed class SendConfirmationStep : SideEffectStep<OrderData>
{
    public override ResourceKind? ResourceKind => EvalApp.ResourceKind.Network;
    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        await Task.Yield();
        return data with { Status = "Confirmed" };
    }
}

public interface IOrderEventBus { ValueTask PublishAsync(string evt, CancellationToken ct); }
public sealed class InMemoryBus : IOrderEventBus
{
    public ValueTask PublishAsync(string evt, CancellationToken ct) => ValueTask.CompletedTask;
}

// No parameterless ctor — a real constructor-injected dependency, same shape as a DI-registered
// service. AddStep<TStep>()'s `new()` constraint can never accept this; AddStep<TStep>(name, factory)
// (no `new()` required) is the sanctioned way to add it.
public sealed class PublishEventStep(IOrderEventBus bus) : CrossDomainStep<OrderData, IOrderEventBus>(bus)
{
    public override ResourceKind? ResourceKind => EvalApp.ResourceKind.Network;
    public override async ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct)
    {
        await Shared.PublishAsync("OrderPlaced", ct);
        return data with { Status = "Published" };
    }
}

Gotchas / invariants:

  • SideEffectStep<T> (and anything deriving from it, like CrossDomainStep<T,TShared>) cannot be added with AddStep(name, instance) — passing an instance binds to an [Obsolete(error: true)] overload and fails to compile, including inside a .Gate(...). Add one via AddStep<TStep>() (needs a parameterless ctor) or AddStep<TStep>(name, factory) (no ctor shape requirement — the factory delegate builds the instance) — both auto-gate it on its declared ResourceKind.
  • That auto-gate requires the resource to already be registered on the app builder — either AddStep<TStep> form, for a step whose ResourceKind is Network, throws InvalidOperationException("ResourceKind 'network' has not been registered...") at pipeline-build time unless you called .WithResource(ResourceKind.Network, ...) first.
  • AddStep<TStep>(name, factory) runs factory() immediately, synchronously, at build time (inside the .AddStep(...) call) — not deferred to RunAsync. A null-returning factory throws ArgumentNullException at build time, not at run time.
  • If you'd rather gate explicitly without a dedicated SideEffectStep<T> subclass, write the side effect as a plain async lambda inside a .Gate(...) instead (see Resource gating).
  • Plain IStep<T>/PureStep<T>/AsyncStep<T> do not auto-catch exceptions — an unhandled throw propagates up and the pipeline reports Failure. Only context-aware steps (Context-aware steps) catch internally.
  • Always propagate the CancellationToken into every awaited call inside a step.

4. Adding steps, fallback, and conditional branches <a name="adding-steps"></a>

AddStep accepts any of these forms (same set on IEmptyTaskBuilder<T>, ITaskBuilder<T>, ISubTaskBuilder<T>, and IConditionalBranchBuilder<T>):

.AddStep("Name", d => d with { Status = "X" })                              // Func<T,T>
.AddStep("Name", async (d, ct) => { await Task.Yield(); return d; })          // async lambda
.AddStep("Name", someIStepInstance)                                          // IStep<T> instance
.AddStep("Name", somePureStepInstance)                                       // PureStep<T> instance
.AddStep("Name", someAsyncStepInstance)                                      // AsyncStep<T> instance
.AddStep<MyStep>("Name")                                                     // resolved via IStepFactory

AddStep<TStep>(name, factory) — now available on IEmptyTaskBuilder<T>, ITaskBuilder<T>, ISubTaskBuilder<T>, IConditionalBranchBuilder<T>, and ISagaBuilder<T> (and as AddBranch<TStep>(name, factory) on IParallelGroupBuilder<T>) — builds the instance from a Func<TStep> instead of a parameterless constructor — the sanctioned way to add a SideEffectStep<T>/CrossDomainStep<T,TShared> that needs a constructor-injected dependency (see Step kinds). Same dispatch/auto-gating on every one of them:

.ForEach(select: d => d.Items, merge: (d, r) => d with { Items = r.ToList() }, collectionName: "Items",
    configure: sub => sub.AddStep<MyStep>("Name", () => new MyStep(injectedDep)))
.If(d => d.X, then: b => b.AddStep<MyStep>("Name", () => new MyStep(injectedDep)))
.BeginSaga().AddStep<MyStep>("Name", () => new MyStep(injectedDep)).EndSaga()
.AddParallelGroup(g => g.AddBranch<MyStep>("Name", () => new MyStep(injectedDep)))

AddStepWithFallback — run a primary step, and if it throws, run a fallback instead (two overloads: lambdas, or IStep<T> instances):

using EvalApp;

Eval.App("Fallback")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStepWithFallback(
                "Risky",
                primary:  d => throw new InvalidOperationException("primary unavailable"),
                fallback: d => d with { Status = "FallbackUsed" })
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new OrderData("Carl", 10m));
// result.GetData().Status == "FallbackUsed"

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

If — conditional branch (two overloads: builder actions, or IStep<T> instances for then/else):

.If(d => d.Total > 100,
    then: b => b.AddStep("VipDiscount", d => d with { Total = d.Total * 0.8m, Status = "VIP" }),
    @else: b => b.AddStep("StandardProcess", d => d with { Status = "Standard" }))

IConditionalBranchBuilder<T> (the then/@else parameter type) supports its own AddStep overloads, nested If, ForEach, and Gate — branches can be arbitrarily deep. If no @else is given and the predicate is false, the step reports Skipped, not Success.

Classify <a name="classify"></a> — first-match-wins classification cascade, on IEmptyTaskBuilder<T>/ITaskBuilder<T>/ISubTaskBuilder<T>. Equivalent to nested If calls with the "not yet matched, try the next rule" guard threaded automatically — names the pattern instead of repeating N near-identical If blocks:

.Classify(
    (t => t.Amount > 1000, b => b.AddStep("High",   t => t with { Tier = "High" })),
    (t => t.Amount > 100,  b => b.AddStep("Medium", t => t with { Tier = "Medium" })),
    (t => true,            b => b.AddStep("Low",    t => t with { Tier = "Low" })))

Cases are tried in order; the first When that returns true runs its Then branch and the rest are skipped (exactly like a chain of If(...).@else(If(...).@else(If(...)))). Put a catch-all t => true last if every input must be classified.

Classify<TResult> (added 2026-10-03) — same first-match-wins shape, but Then receives the SAME value When just computed, so a case that needs its predicate's own computed result doesn't have to recompute it or thread it through a mutable field:

// Self-contained — ClassifyInput/Classify<int> compiled and run exactly as written (verified via
// EvalApp.Tests/Core/ChunkedWorkBenchmark.cs's real Classify<TResult> usage, same signature shape).
public sealed record ClassifyInput(int[] Items, string Tier = "");

.Classify<int>(
    ((ClassifyInput t) => (t.Items.Length > 1 && t.Items.All(i => i == t.Items[0]), t.Items.Length),
     (ClassifyInput t, int repeatCount) => t with { Tier = $"Repeated x{repeatCount}" }),
    ((ClassifyInput t) => (true, 0),
     (ClassifyInput t, int _) => t with { Tier = "Unique" }))

When returns (bool Matched, TResult Value); Then is Func<T, TResult, T>. Compiles to ONE plain step (not a branch-builder chain), so each Then here is a single transform — for a case that needs multiple steps/gates, use the branch-builder Classify above instead. Anti-pattern note: neither Classify overload nor ForEach is intended for hot, pure-CPU, no-await-anywhere classification at per-row granularity (sub-microsecond comparisons on thousands-to-millions of rows, re-run many times) — see ChunkedWork below.


5. Parallel groups and custom merge strategies <a name="parallel-groups"></a>

AddParallelGroup runs a fixed set of branches concurrently (not per-item — see ForEach for that), then merges their results. Branches are genuinely dispatched onto separate tasks (fixed 2026-10-03 — previously a synchronous branch ran to completion on the calling thread before the next branch's call was even made, silently serializing two sync branches; MEASURED after the fix: a 5ms/5ms two-branch group now lands at parity with a hand-rolled Task.Run+ Task.WaitAll fork-join, see EvalApp/CLAUDE.md "3.0 fix-round additions"). IParallelGroupBuilder<T> .AddBranch has three overloads — lambda, generic new(), and IStep<T> instance:

using EvalApp; // IMergeStrategy<T> is one of the public extension seams, in the same root namespace

Eval.App("Parallel")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddParallelGroup(
                pg => pg
                    .AddBranch("CalcTax", d => d with { Tax = d.Total * 0.08m })  // Func<T,T>
                    .AddBranch<CalcShippingBranch>("CalcShipping")                // generic new()
                    .AddBranch("CalcHandling", new CalcHandlingFeeStep()),         // IStep<T> instance
                mergeStrategy: new SumNumericFieldsMerge())
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New",
    decimal Tax = 0m, decimal Shipping = 0m, decimal Handling = 0m);

public sealed class CalcShippingBranch : PureStep<OrderData>
{
    public override OrderData Execute(OrderData data) => data with { Shipping = 9.99m };
}

public sealed class CalcHandlingFeeStep : IStep<OrderData>
{
    public ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct = default)
        => ValueTask.FromResult(data with { Handling = 1.5m });
}

public sealed class SumNumericFieldsMerge : IMergeStrategy<OrderData>
{
    public OrderData Merge(OrderData original, IReadOnlyList<OrderData> results)
        => original with
        {
            Tax      = results.Max(r => r.Tax),
            Shipping = results.Max(r => r.Shipping),
            Handling = results.Max(r => r.Handling),
        };
}

IMergeStrategy<T> is one of only four public extension seams that live directly in the root EvalApp namespace alongside everything else (see the reference table). Omit mergeStrategy to get the default ("first branch to change a field wins"); supply your own when two branches might legitimately write the same field and that should be treated as a real conflict rather than silently resolved.

Note on step kinds in AddBranch: there is no AddBranch(name, PureStep<T>) overload — only Func<T,T>, AddBranch<TStep>(), and AddBranch(name, IStep<T> instance). A PureStep<T> instance must go through the generic AddBranch<TStep>() form (needs a parameterless constructor) or be wrapped in a lambda calling .Execute(...).


6. Sub-tasks and ForEach <a name="foreach"></a>

AddSubTaskFor — run a nested pipeline over a single property, then merge the (possibly different-typed) result back:

.AddSubTaskFor(
    select: d => d.Total,
    merge: (d, newTotal) => d with { Total = newTotal },
    propertyName: "Total",
    configure: sub => sub.AddStep("Double", (decimal v) => v * 2))

ForEach — run an independent, data-parallel pipeline over each item of a collection, then merge the results back. Every overload shares select (extracts the collection), merge (combines results into the parent), collectionName (diagnostics label), and configure (the per-item pipeline); they differ only in how parallelism/failure handling is declared:

using EvalApp;

Action<ISubTaskBuilder<LineItem>> calc =
    sub => sub.AddStep("Calc", (LineItem i) => i with { LineTotal = i.Price * i.Quantity });

// (a) default parallelism
Eval.App("ForEachA").DefineDomain("Orders").DefineTask<LineItemsData>("CalcLineTotals")
    .ForEach(
        select: d => d.Items,
        merge: (d, items) => d with { Items = items.ToList() },
        collectionName: "Items",
        configure: calc)
    .Run(out ICompiledPipeline<LineItemsData> pipelineA).Build();

// (b) fixed int parallelism
Eval.App("ForEachB").DefineDomain("Orders").DefineTask<LineItemsData>("CalcLineTotals")
    .ForEach(select: d => d.Items, merge: (d, items) => d with { Items = items.ToList() },
        collectionName: "Items", maxParallelism: 2, configure: calc)
    .Run(out ICompiledPipeline<LineItemsData> pipelineB).Build();

// (c) Func<int> parallelism — re-evaluated per run (e.g. from live config)
var liveMax = 3;
Eval.App("ForEachC").DefineDomain("Orders").DefineTask<LineItemsData>("CalcLineTotals")
    .ForEach(select: d => d.Items, merge: (d, items) => d with { Items = items.ToList() },
        collectionName: "Items", getMaxParallelism: () => liveMax, configure: calc)
    .Run(out ICompiledPipeline<LineItemsData> pipelineC).Build();

// (d) tuner-friendly TunableConfig parallelism
Eval.App("ForEachD").DefineDomain("Orders").DefineTask<LineItemsData>("CalcLineTotals")
    .ForEach(select: d => d.Items, merge: (d, items) => d with { Items = items.ToList() },
        collectionName: "Items", parallelism: Tunable.ForItems(), configure: calc)
    .Run(out ICompiledPipeline<LineItemsData> pipelineD).Build();

// (e) TunableConfig + a separate minItemsForParallel threshold
Eval.App("ForEachE").DefineDomain("Orders").DefineTask<LineItemsData>("CalcLineTotals")
    .ForEach(select: d => d.Items, merge: (d, items) => d with { Items = items.ToList() },
        collectionName: "Items", parallelism: Tunable.Between(1, 8, 4),
        minItemsForParallel: Tunable.FixedAt(10), configure: calc)
    .Run(out ICompiledPipeline<LineItemsData> pipelineE).Build();

// (f) int parallelism + ForEachFailureMode — honoured at every item count (see table below)
Eval.App("ForEachF").DefineDomain("Orders").DefineTask<LineItemsData>("CalcLineTotals")
    .ForEach(select: d => d.Items, merge: (d, items) => d with { Items = items.ToList() },
        collectionName: "Items", maxParallelism: 2, failureMode: ForEachFailureMode.ContinueOnError,
        configure: sub => sub.AddStep("Calc", (LineItem i) =>
            i.Name == "Bad" ? throw new InvalidOperationException("bad item") : i with { LineTotal = i.Price * i.Quantity }))
    .Run(out ICompiledPipeline<LineItemsData> pipelineF).Build();

// (g) TunableConfig parallelism + ForEachFailureMode
Eval.App("ForEachG").DefineDomain("Orders").DefineTask<LineItemsData>("CalcLineTotals")
    .ForEach(select: d => d.Items, merge: (d, items) => d with { Items = items.ToList() },
        collectionName: "Items", parallelism: Tunable.ForItems(), failureMode: ForEachFailureMode.CollectAndThrow,
        configure: calc)
    .Run(out ICompiledPipeline<LineItemsData> pipelineG).Build();

public sealed record LineItem(string Name, int Quantity, decimal Price, decimal LineTotal = 0m);
public sealed record LineItemsData(List<LineItem> Items);

ForEachFailureMode:

Value Behavior
CollectAndThrow (default) Run every item; if any failed, report Failure with every exception collected. Full visibility in one run.
FailFast Cancel remaining items as soon as one fails.
ContinueOnError Drop failed items; report Success with only the successful ones merged. Best-effort / partial results.

Below the effective MinItemsForParallel threshold (default 4, from Tunable.ForItems()/the adaptive tuner defaults), ForEach runs a sequential inline path instead of the parallel dispatch path (pure scheduling-overhead optimisation, not a behavior change) — it honours ForEachFailureMode identically on both paths. ContinueOnError keeps every survivor and reports Success; CollectAndThrow runs every item to completion and aggregates every failure; FailFast still stops at the first failing item. This holds at every item count, below or at/above the threshold.

Per-item progress: ISubTaskBuilder<T>.OnItemCompleted(callback). Registered inside any ForEach/AddSubTaskFor configure block, fired once per item right after its result (success, failure, or skipped) is known — on both the sequential and parallel execution paths. The sanctioned way to drive a live progress bar (e.g. a Showroom drag-and-drop upload over several files) without rolling a hand-written loop, which .ForEach itself exists to replace (see Anti-patterns #1):

.ForEach(select: d => d.Items, merge: (d, r) => d with { Items = r.ToList() }, collectionName: "Items",
    configure: sub => sub
        .OnItemCompleted((item, result) => progress.Report((item, result.IsSuccess)))
        .AddStep("Calc", (LineItem i) => i with { LineTotal = i.Price * i.Quantity }))

Only the last registration wins if called more than once on the same sub-builder. Works identically on every ForEach overload (and AddSubTaskFor) because they all share this one configure delegate — no per-overload wiring needed.

Parallelism is always declared bounds, not a hard promise: without tuning enabled a gate/ForEach holds at its declared default; with .WithTuning()/.WithBayesianTuning() the tuner adjusts within [Min, Max] at runtime (see Tuning modes).

ChunkedWork: chunk-level fan-out for cheap rows, many repetitions <a name="chunkedwork"></a>

ForEach<TItem> is per-ITEM: a fresh TItem plus whatever scratch a step allocates, every row, every call. That's the right cost model for I/O-bound/resource-gated work, and the wrong one for a tight, pure-CPU, no-I/O loop run thousands of times over a large cheap collection (a numeric fit re-run every outer optimizer iteration; a classification cascade over tens of thousands of rows) — MEASURED 2026-10-03: one real conversion onto ForEach regressed 11x (8.81s→98.42s) because scratch buffers got reallocated per row per outer iteration instead of once. ChunkedWork.Run (static, root EvalApp, NOT part of the fluent builder chain — call it from inside a plain AddStep) partitions into ~core-count contiguous ranges instead of per-item dispatch, so scratch is allocated ONCE PER CHUNK:

using EvalApp;
// Illustrative — GetRows()/ProcessRow() stand in for your own data/work; the ChunkedWork.Run call
// itself is the real, verified API (exercised as written in EvalApp.Tests/Core/ChunkedWorkBenchmark.cs).

var rows = GetRows(); // IReadOnlyList<double>, cheap per-row work, called many times
var partialSums = ChunkedWork.Run(rows, (items, startIndex, count) =>
{
    var scratch = new double[10]; // ONE allocation per chunk, not per row
    double sum = 0;
    for (int i = startIndex; i < startIndex + count; i++)
        sum += ProcessRow(scratch, items[i]);
    return sum;
});
double total = partialSums.Sum(); // the caller's own reduction — ChunkedWork never merges for you

processChunk runs on a worker Task.Run per chunk; chunkCount defaults to Environment.ProcessorCount. There is no cancellation token, no middleware, no StepContext — that overhead is exactly what this primitive exists to avoid. It does not replace ForEach for I/O-bound or resource-gated per-item work; it exists for the opposite shape.


7. Cross-pipeline bridges and streaming Materialize <a name="bridges-and-materialize"></a>

AddReadOnlyBridge — run an independent IStep<T> once against a seed value, then merge its result into the current pipeline's data without that step's result overwriting it wholesale. Despite the parameter name, sourcePipeline is any IStep<T> — not a compiled ICompiledPipeline<T> (there is no overload that takes one; wrap its RunAsync in your own IStep<T> if you need to bridge an actual compiled pipeline):

using EvalApp;

Eval.App("Bridge")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Init", d => d)
            .AddReadOnlyBridge(
                "ReadConfig",
                sourcePipeline: new LoadConfigStep(),            // any IStep<T>, run once against bridgeData
                bridgeData: new OrderData("", 0m),
                merge: (current, configResult) => current with { Total = current.Total + configResult.Total })
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new OrderData("Frank", 10m));
// result.GetData().Total == 52 (10 + the bridge step's 42)

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

public sealed class LoadConfigStep : IStep<OrderData>
{
    public ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct = default)
        => ValueTask.FromResult(data with { Total = 42m });
}

Materialize — drain an IAsyncEnumerable<TItem> property (e.g. a gRPC server-streaming response) into a List<TItem> before any ForEach/parallel node needs a concrete collection, with cancellation propagated through the drain:

using EvalApp;

Eval.App("Materialize")
    .DefineDomain("Orders")
        .DefineTask<StreamedData>("DrainStream")
            .Materialize<int>(
                "DrainScores",
                source: d => Source(1, 2, 3),       // Func<T, IAsyncEnumerable<TItem>>
                merge: (d, items) => d with { Scores = items })
        .Run(out ICompiledPipeline<StreamedData> pipeline)
    .Build();

var result = await pipeline.RunAsync(new StreamedData([]));
// result.GetData().Scores == [1, 2, 3]

async IAsyncEnumerable<int> Source(params int[] values)
{
    foreach (var v in values) { await Task.Yield(); yield return v; }
}

public sealed record StreamedData(List<int> Scores);

8. Resource gating: ResourceKind, Tunable, Gate <a name="resource-gating"></a>

ResourceKind identifies a named, shared pool that a Gate contends for:

ResourceKind.Network   ResourceKind.DiskIO   ResourceKind.Cpu   ResourceKind.Database
ResourceKind.Of("report-engine")   // your own custom-named pool

Tunable factory methods build a TunableConfig(Min, Max, Default) record:

Method Shape
Tunable.Between(min, max, default) explicit bounds
Tunable.FixedAt(value) Min == Max == Default == value — no tuning possible
Tunable.ForCpu() Min = max(1, cpus/2), Max = cpus*2, Default = cpus
Tunable.ForItems() Min = 4, Max = int.MaxValue, Default = cpus*2
Tunable.ForItems(default) Min = 1, Max = int.MaxValue, Default = default
Tunable.InlineBelow(threshold) fixed at threshold (inline/no-parallel shorthand)
Tunable.InlineBelowBetween(min, max, default) same shape as Between
Tunable.ModelOps() fixed at 1 — exclusive access (e.g. GPU training)
Tunable.DiskIo() Min=1, Max=4, Default=2
Tunable.Network() Min=1, Max=2, Default=1

Declare a resource's bounds at the app level, then Gate any block of steps against it (two Gate overloads: built-in/custom ResourceKind, or a (name, TunableConfig) pair that registers its own named pool inline):

using EvalApp;

Eval.App("Gate")
    .WithResource(ResourceKind.Database, Tunable.Between(1, 8, 2))
    .WithResource(ResourceKind.Of("report-engine"), maxConcurrency: 1)
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .Gate(ResourceKind.Database, onWaiting: null, configure: g => g
                .AddStep("CheckInventory", d => d with { Status = "Checked" }))
            .Gate("CustomPool", Tunable.FixedAt(1),
                onWaiting: d => Console.WriteLine($"waiting: {d.Status}"),
                configure: g => g.AddStep("RunReport", d => d with { Status = "Reported" }))
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

onWaiting (nullable Action<T>) fires if the step has to queue for the resource — use it for a "waiting" log line or UI state; pass null to ignore it. WithResource itself has three overloads: (kind) (bounds default to the engine's internal default), (kind, TunableConfig), and (kind, int maxConcurrency) (shorthand for FixedAt).


9. Pressure signals and window budgets <a name="pressure"></a>

Unlike a Gate, a pressure resource never blocks — it only measures. .Pressure(name, configure) wraps a block of steps, times how long they actually take, and calls IPressureResource.Consume with the elapsed milliseconds; steps inside (or elsewhere, by reading the resource) decide their own degradation strategy from the resulting Pressure (0.0 = empty, 1.0 = at budget, >1.0 = over).

using EvalApp; // IPressureResource — one of the 4 public extension seams, same root namespace

Eval.App("Pressure")
    .WithPressure(new SimpleBudget("render-budget"))
    .WithWindowBudget(cyclesPerSecond: 60)   // registers the BUILT-IN resource named "window-budget"
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Init", d => d)                   // Pressure/WindowBudget need ITaskBuilder<T>
            .Pressure("render-budget", configure: sub => sub
                .AddStep("Heavy", d => d with { Status = "Rendered" }))
            .WindowBudget(configure: sub => sub         // shorthand for .Pressure("window-budget", ...)
                .AddStep("PerFrame", d => d with { Total = d.Total + 1 }))
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

public sealed class SimpleBudget : IPressureResource
{
    public SimpleBudget(string name) => Name = name;
    public string Name { get; }
    private float _accumulated;
    public float Pressure => _accumulated / 10f;      // 10ms budget, your own scale
    public void Consume(float amount) => _accumulated += amount;
    public void Reset() => _accumulated = 0f;
}

Gotchas:

  • .Pressure(name, ...)/.WindowBudget(...) are only on ITaskBuilder<T>/ISubTaskBuilder<T> (post-first-step) — calling them directly on an empty task (before any AddStep) is a compile error; add a step first.
  • .Pressure(name, ...) throws InvalidOperationException at build time if name was never registered via WithPressure(...) or WithWindowBudget(...) on the app builder.
  • WithWindowBudget(cyclesPerSecond) registers under the fixed name "window-budget"; WithWindowBudget(name, cyclesPerSecond) registers the same WindowBudgetPressure under a custom name so you can run more than one frame-budget scope at once.

10. Sagas: compensation on failure <a name="sagas"></a>

BeginSaga(CompensationPolicy policy = BestEffort) / EndSaga() wrap a block of forward steps, some of which declare a compensating action. If a later step throws, every prior step's compensation runs in reverse (LIFO) order.

using EvalApp;

Eval.App("Saga")
    .WithResource(ResourceKind.Database, Tunable.FixedAt(2))
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .BeginSaga(CompensationPolicy.BestEffort)   // BeginSaga() as the very first call works too
                .AddStepWithCompensation(
                    "ReserveInventory",
                    forward:    d => d with { Status = "Reserved" },
                    compensate: d => d with { Status = "Unreserved" })
                .AddGate(
                    ResourceKind.Database,
                    onWaiting: null,
                    configure: g => g.AddStep("WriteLedger", d => d with { Total = d.Total }),
                    compensate: new RevertLedgerStep())
                .AddStep(
                    "ChargePayment",
                    d => d.Total > 1_000_000m
                        ? throw new InvalidOperationException("Payment declined")
                        : d with { Status = "Charged" })
            .EndSaga()
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

var ok = await pipeline.RunAsync(new OrderData("Ivy", 10m));             // Success, Status == "Charged"
var failed = await pipeline.RunAsync(new OrderData("Jin", 2_000_000m));  // Failure — compensations ran

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

public sealed class RevertLedgerStep : IStep<OrderData>
{
    public ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct = default)
        => ValueTask.FromResult(data with { Status = "LedgerReverted" });
}

ISagaBuilder<T> also has AddStep (lambda / async lambda / IStep<T> / generic new()), AddForEach<TItem> (two overloads — with/without a compensate step, plus sideEffectOnly), and AddMaterialize<TItem>.

CompensationPolicy:

Value Behavior
BestEffort (default) Run every compensation even if one of them throws.
AbortOnFirst Stop compensating at the first compensation failure.
SwallowErrors Run every compensation, swallow any compensation-step exceptions.

.BeginSaga() works correctly as the very first call on a task (before any .AddStep(...)), and identically after a prior AddStep — both forms compile and run the same way.


11. Middleware <a name="middleware"></a>

IStepMiddleware<T> wraps EACH named step individually (not the chain once) for cross-cutting concerns (timing, retry, audit, per-step tracing). No named middlewares ship pre-built — this is a seam you implement, not a library of ready-made ones.

BREAKING in 3.0 (2026-10-03): ExecuteAsync gained a string stepName parameter — the step being wrapped, closing the "no per-step tracing seam" gap the CouncilAudit case study found (todo/evalapp-native-apps.md). Since middleware already wraps every step one at a time, this is the name of that step, not a whole-chain label. Add the parameter to any existing implementation; there is no other change.

using EvalApp;
using System.Diagnostics;

Eval.App("Middleware")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .WithMiddleware(new TimingMiddleware<OrderData>())   // on IEmptyTaskBuilder<T> or ITaskBuilder<T>
            .AddStep("Validate", d => d with { Status = "Validated" })
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

public sealed class TimingMiddleware<T> : IStepMiddleware<T>
{
    public async ValueTask<PipelineResult<T>> ExecuteAsync(
        T data, string stepName, Func<T, CancellationToken, ValueTask<PipelineResult<T>>> next, CancellationToken ct = default)
    {
        var sw = Stopwatch.StartNew();
        var result = await next(data, ct);
        Console.WriteLine($"step '{stepName}' took {sw.ElapsedMilliseconds}ms");
        return result;
    }
}

Plain steps (IStep<T>/PureStep<T>/AsyncStep<T>) do not catch their own exceptions — a middleware implementing retry must catch inside next(...) itself.


12. Dependency injection (step factories) <a name="di"></a>

AddStep<TStep>(name)/AddBranch<TStep>(name) resolve TStep through an IStepFactory. The default, DefaultStepFactory (a shared .Instance singleton), uses Activator.CreateInstance — the step needs a public parameterless constructor. To resolve from your own container instead, use ServiceProviderStepFactory, which checks the container first and falls back to Activator.CreateInstance for anything not registered:

using EvalApp;

// Any real IServiceProvider works (Microsoft.Extensions.DependencyInjection's ServiceCollection,
// etc.); this minimal one keeps the snippet dependency-free:
var services = new SimpleServiceProvider();
services.Register(typeof(ValidateOrderStep), new ValidateOrderStep("DI-resolved"));

Eval.App("Di")
    .WithStepFactory(new ServiceProviderStepFactory(services))
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep<ValidateOrderStep>("Validate")     // resolved from DI, not Activator
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

public sealed class ValidateOrderStep : IStep<OrderData>
{
    private readonly string _tag;
    // A literal parameterless ctor to satisfy AddStep<TStep>()'s `new()` constraint (the
    // Activator.CreateInstance fallback path); the tagged ctor is what your DI container resolves.
    public ValidateOrderStep() : this("default") { }
    public ValidateOrderStep(string tag) => _tag = tag;
    public ValueTask<OrderData> ExecuteAsync(OrderData data, CancellationToken ct = default)
        => ValueTask.FromResult(data with { Status = _tag });
}

public sealed class SimpleServiceProvider : IServiceProvider
{
    private readonly Dictionary<Type, object> _services = new();
    public void Register(Type type, object instance) => _services[type] = instance;
    public object? GetService(Type serviceType) => _services.TryGetValue(serviceType, out var s) ? s : null;
}

Gotcha: the generic constraint where TStep : class, new() on AddStep<TStep>() is enforced by the compiler, independent of which IStepFactory you register. A constructor with all-optional parameters does not satisfy new() — the type needs a literal zero-parameter constructor, even if you intend DI to always supply the real one via a different overload. Note: this IStepFactory path is for plain IStep<T> implementers only — SideEffectStep<T>/ CrossDomainStep<T,TShared> are constructed directly via AddStep<TStep>()'s own Activator branch (bypassing any registered IStepFactory entirely), so a registered DI container never resolves them either way. If one needs a constructor-injected dependency, use AddStep<TStep>(name, factory) instead (see Adding steps) — no new() required.


13. Context-aware steps <a name="context"></a>

If a step needs data that's constant for a whole app run (config, a connection factory) or a whole domain (a tenant ID, a region), subclass GlobalContext/DomainContext instead of threading it through every record:

using EvalApp;

Eval.App("Context")
    .WithContext(new AppGlobal { Prefix = "ORD" })
    .DefineDomain("Orders", new OrderDomain { Region = "US" })
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep<TagWithRegionStep>("TagRegion")   // ContextPureStep: AddStep<TStep>() only
            .AddStep<NotifyStep>("Notify")              // ContextSideEffectStep: same rule
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");
public sealed class AppGlobal : GlobalContext { public string Prefix { get; init; } = ""; }
public sealed class OrderDomain : DomainContext { public string Region { get; init; } = ""; }

public sealed class TagWithRegionStep : ContextPureStep<AppGlobal, OrderDomain, OrderData>
{
    protected override ValueTask<OrderData> TransformAsync(
        OrderData data, AppGlobal global, OrderDomain domain, CancellationToken ct)
        => ValueTask.FromResult(data with { Status = $"{global.Prefix}:{domain.Region}" });
}

public sealed class NotifyStep : ContextSideEffectStep<AppGlobal, OrderDomain, OrderData>
{
    protected override async ValueTask<OrderData> ExecuteAsync(
        OrderData data, AppGlobal global, OrderDomain domain, CancellationToken ct)
    {
        await Task.Yield();   // real code: the actual notification I/O
        return data with { Status = data.Status + ":Notified" };
    }
}

If a step needs DomainContext but no global state, use the built-in NullGlobalContext (shown here continuing the same OrderDomain/OrderData types as above):

public sealed class TagWithRegionOnlyStep : ContextPureStep<NullGlobalContext, OrderDomain, OrderData>
{
    protected override ValueTask<OrderData> TransformAsync(
        OrderData data, NullGlobalContext global, OrderDomain domain, CancellationToken ct)
        => ValueTask.FromResult(data with { Status = domain.Region });
}

Gotchas / invariants:

  • Always add a context step with AddStep<TStep>(), never AddStep(name, instance). ContextPureStep<TGlobal,TDomain,T>/ContextSideEffectStep<TGlobal,TDomain,T> implement the internal engine's step contract (needed to inject context before the step runs), not the public IStep<T> the instance-overload expects — AddStep("x", new MyContextStep()) is a CS1503 compile error from outside the assembly.
  • Both catch their own exceptions internally and translate a thrown exception into a Failure result — unlike plain IStep<T>/PureStep<T>/AsyncStep<T>.
  • Global/Domain are populated as properties on the step instance before it runs; there is no StepContext parameter anywhere on these public base classes.
  • WithContext(globalContext) is app-level (one per app); DefineDomain(name, domainContext) is per-domain. If you never call WithContext, context-aware steps whose TGlobal is NullGlobalContext still work (no global state needed); for any other TGlobal you must supply one.

14. Licensing <a name="licensing"></a>

No feature on this page is gated behind a tier or a license key — every capability above runs at full concurrency whether or not you pass one; Build(null) and Build(key) behave identically at runtime. See License at the bottom of this page for the commercial terms. The license system below is still the real, fully-functional offline verifier every Evaluated Applications product shares — kept ready for a possible future advanced tier — but it currently caps nothing.

Everything licensing-related — the high-level validator AND the lower-level verifier types — lives in the same root EvalApp namespace as everything else. (In 1.x these were split across EvalApp.Consumer and EvalApp.Licensing, with two distinct LicenseMode enums; 2.0 merged both into one EvalApp.LicenseMode, used everywhere, no cast needed.)

using EvalApp;

string? licenseKey = null;   // or a real key string — both paths below never throw

// High-level, recommended surface:
var validator = LicenseValidator.ForEvalApp();           // or LicenseValidator.For("your-product")
LicenseMode mode = validator.Check(licenseKey);            // Unlicensed | Licensed — never throws
LicenseMode cached = validator.CheckPeriodic(licenseKey, TimeSpan.FromSeconds(5)); // 60fps-loop-safe

Eval.App("Orders")
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Noop", d => d)
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build(licenseKey);   // null/empty/invalid/expired key all degrade to Unlicensed — never throws

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

Lower-level types (same system, used internally by LicenseValidator and available directly if you're integrating licensing outside a pipeline):

using EvalApp;

string? licenseKey = null;

License license = LicenseVerifier.Verify(licenseKey);       // invalid/missing -> License.Free, never throws
LicenseTier tier = license.TierFor("evalapp");                // Free | Pro | Enterprise
bool allowed = license.Allows("evalapp", LicenseTier.Pro);

// The claims/payload/encoding types behind a key (useful if you're building your own license
// tooling against the same format; the signing half never ships in this package):
var claims = new LicenseClaims("Acme", "trial", DateOnly.FromDateTime(DateTime.UtcNow.AddYears(1)),
    new Dictionary<string, LicenseTier> { ["evalapp"] = LicenseTier.Pro });
byte[] payload = LicensePayload.Serialize(claims);
LicenseClaims? roundTripped = LicensePayload.Parse(payload);
string encoded = Base64Url.Encode(payload);
byte[] decoded = Base64Url.Decode(encoded);

Invariants: verification is 100% offline (ECDsa P-256) — there is no network call, ever. License.Free is the always-available baseline (no key needed). A "*" grant in a key's claims unlocks every product at once.


15. Tuning modes <a name="tuning"></a>

Two opt-in modes (enable either by calling it — neither is license-gated):

using EvalApp;

Eval.App("Tuned")
    .WithTuning(storePath: "tuning.json")        // reactive: rediscovers the optimum from scratch each restart
    // .WithBayesianTuning(storePath: "bayes.json") // predictive: persists a performance model across restarts
    .DefineDomain("Orders")
        .DefineTask<OrderData>("ProcessOrder")
            .AddStep("Noop", d => d)
        .Run(out ICompiledPipeline<OrderData> pipeline)
    .Build();

public sealed record OrderData(string CustomerName, decimal Total, string Status = "New");

Both take an optional store path (defaults if omitted) and share the same underlying phase machine — switching between them doesn't reset state. You don't need to configure the phase machine for good behavior; the defaults are derived from a simulation harness. To override them explicitly, load your own JSON file with .WithTunerSettings(path) — the file is read eagerly (missing/invalid path throws at the point you call it), and must match this exact shape:

{
  "ImprovementThreshold": 0.04,
  "ConvergenceStddevThreshold": 0.04,
  "ConvergingRunsNeeded": 5,
  "MonitoringRegressionThreshold": 0.19,
  "MonitoringImprovementThreshold": 0.3,
  "MaxProbeInterval": 18,
  "MaxHistorySize": 50,
  "ExplorationWindowSize": 28,
  "EmergencyDegradationThreshold": 0.36,
  "EmergencyRunsNeeded": 4,
  "ConvergingInstabilityThreshold": 1.5,
  "MinItemsForParallel": 4
}

(These are the same production defaults .WithTuning() uses out of the box — informational, not a stability contract; a future release may re-derive them.) MinItemsForParallel here is the app-wide fallback for the gotcha in ForEach — an individual ForEach call's own minItemsForParallel/TunableConfig argument, if given, takes precedence over this file.


16. Inspecting a pipeline <a name="inspecting"></a>

ICompiledPipeline<T> is intentionally opaque: its entire public surface is Name (the name given at build time) and RunAsync(data, ct). There is no exposed step tree and no visualization/export API in the public facade.


17. Anti-patterns to avoid <a name="anti-patterns"></a>

EvalApp's tuner and gates can only account for work they can see — anything that happens outside the declared pipeline topology is invisible to both:

  • Task.WhenAll inside a step body instead of a declared .ForEach(...).
  • A background Task started and stored on the data record — it races the pipeline, ungated.
  • Async work before/after RunAsync, outside the pipeline — unthrottled, untuned.
  • A shared mutable collection written from inside a ForEach item — not thread-safe; let each item own its own result and collect them in merge.
  • Mutating the data record instead of using with — breaks the no-lock parallel-ForEach guarantee.
  • Instance fields on a step class holding per-run state — steps are singletons reused across every concurrent invocation; per-run state belongs on the data record only.
  • Rebuilding the pipeline per request — discards all accumulated tuner state; build once at startup, run many times.
  • Blocking I/O inside a sync step — always use the async overload for I/O.
  • Classifying/transforming a million cheap rows a second through Classify/ForEach/plain steps. Not what the step/middleware/StepContext machinery is priced for — MEASURED: a real conversion onto ForEach regressed 11x on a cheap, many-repetition loop. Use [ChunkedWork] (#chunkedwork) for that shape instead.

18. Full public surface reference <a name="reference"></a>

Everything public lives directly in the root EvalApp namespace. using EvalApp; is the only using directive a consumer ever needs for this package's own types — if a type isn't reachable that way, it's internal engine and not part of the supported surface, regardless of what a decompiler shows.

  • The fluent API — Eval, IAppBuilder, IDomainBuilder, IEmptyTaskBuilder<T>, ITaskBuilder<T>, IConditionalBranchBuilder<T>, IParallelGroupBuilder<T>, ISubTaskBuilder<T>, ISagaBuilder<T>, ICompiledPipeline<T>, PipelineResult<T>.
  • Step base classes — IStep<T>, PureStep<T>, AsyncStep<T>, SideEffectStep<T>, CrossDomainStep<T,TShared>, ContextPureStep<TGlobal,TDomain,T>, ContextSideEffectStep<TGlobal,TDomain,T>.
  • Context base classes — GlobalContext, DomainContext, NullGlobalContext.
  • DI step factories — IStepFactory, DefaultStepFactory, ServiceProviderStepFactory.
  • Config/value types — ResourceKind, Tunable, TunableConfig, ForEachFailureMode, CompensationPolicy, IStepMiddleware<T>, IMergeStrategy<T>, IPressureResource.
  • Static helpers — ChunkedWork (chunk-level fan-out, §6).
  • Licensing — LicenseValidator, LicenseMode, LicenseTier, LicenseClaims, License, LicenseVerifier, LicensePayload, Base64Url.

Everything else — every internal engine namespace (EvalAppEngine.*: Fluent, Core, Abstractions, Steps, Middleware, Extensions, Compatibility, Diagnostics, Parallel, Tuning, Licensing, Facade) — is internal to the assembly, reachable only by a handful of trusted first-party dependent packages via [InternalsVisibleTo]. It is not part of the supported public surface and is not covered by this page.


19. Migrating from 1.x <a name="migrating"></a>

EvalApp 2.0 is a deliberate breaking change: the public surface moved from the EvalApp.Consumer namespace into the root EvalApp namespace, and the old EvalApp static class (the one that collided with the assembly's own root namespace, forcing an awkward EvalApp.Consumer.EvalApp.App(...) fully-qualified call) is gone.

1. Change the using.

- using EvalApp.Consumer;
+ using EvalApp;

That's the whole migration for the vast majority of code — every type you were already using (IAppBuilder, IStep<T>, PureStep<T>, ResourceKind, Tunable, TunableConfig, ICompiledPipeline<T>, PipelineResult<T>, GlobalContext, DomainContext, ...) kept its exact name and shape; only the namespace it lives in changed.

2. Eval.App(...) is the only entry point. The legacy EvalApp.App(...) static-class alias (already [Obsolete] in 1.9) is removed entirely in 2.0. If you were still calling it (or its fully-qualified EvalApp.Consumer.EvalApp.App(...) form), switch to Eval.App(...).

3. Licensing: one LicenseMode, no cast. 1.x had two distinct LicenseMode enums (EvalApp.Consumer.LicenseMode and EvalApp.Licensing.LicenseMode) that were easy to collide by using both namespaces together. 2.0 has exactly one: EvalApp.LicenseMode. LicenseValidator.Check/ CheckPeriodic return it directly — drop any (LicenseMode)(int)... cast you had. EvalApp.Licensing.* (the lower-level License/LicenseVerifier/LicenseTier/etc.) also moved to the root EvalApp namespace — same fix: change the using, keep the code.

4. If you used [InternalsVisibleTo] trusted-first-party seams (BuildAuthorized, WithProductLicense, InstallTuner — Tracer, HoloDb, EvalApp.Neural, AlgFormer): the internal engine moved from EvalApp.* (e.g. EvalApp.Consumer.Internal, EvalApp.Fluent, EvalApp.Core, EvalApp.Abstractions) to a separate, non-nested EvalAppEngine.* root (e.g. EvalAppEngine.Consumer, EvalAppEngine.Fluent, EvalAppEngine.Core, EvalAppEngine.Abstractions). Update any using EvalApp.Consumer.Internal; (or similar internal namespace) to using EvalAppEngine.Consumer; (etc.). This split exists specifically so the public EvalApp namespace and the internal engine can never silently collide on a shared simple name (IStep<T>, ITaskBuilder<T>, ResourceKind, ...) — nesting the engine under EvalApp.Engine.* was tried first and rejected: C# namespace lookup resolves an enclosing namespace's direct members before any using directive, so every internal-engine file would have silently bound to the public same-named type instead of its own internal one. If you have your own code under an EvalApp.*-prefixed namespace that also usings an internal EvalApp namespace, the same hazard applies to you — don't nest your own namespace under EvalApp.*.

No other behavior changed: every example on this page runs identically to 1.x once the using is updated.

Migrating from 2.x to 3.0 <a name="migrating-3"></a>

3.0 removes "Consumer" as a product concept entirely (the public namespace has been flat root EvalApp since 2.0 — this is just the lingering internal naming catching up) and adds the per-step tracing seam:

  1. IStepMiddleware<T>.ExecuteAsync gained a string stepName parameter (inserted before next). Add the parameter to any implementation; behavior is otherwise unchanged — see §11 Middleware.
  2. If you used the [InternalsVisibleTo] trusted seam, the internal namespace EvalAppEngine.Consumer (introduced in 2.0) is renamed EvalAppEngine.Facade. Update any using EvalAppEngine.Consumer; to using EvalAppEngine.Facade;. No public EvalApp type or namespace is affected — this is purely an internal rename.
  3. ForEach/AddSubTaskFor gained ISubTaskBuilder<T>.OnItemCompleted(callback) and ITaskBuilder<T>/ISubTaskBuilder<T> gained .Classify(...) — both purely additive, see §6 and §17. AddStep<TStep>(name, factory) is now also available on ISubTaskBuilder<T>, IConditionalBranchBuilder<T>, ISagaBuilder<T>, and IParallelGroupBuilder<T>.AddBranch<TStep>(name, factory) — also purely additive.

License

Proprietary — this package ships the compiled library only; see LICENSE for the full terms. Installing and using unmodified copies for internal evaluation and non-commercial development or testing is licensed; commercial, production, or revenue-generating use requires a separate commercial licence from the owner. No feature on this page is gated behind a tier or a licence key — every capability described works identically with or without one. Copyright (c) 2026 Dongyang Stephen Chen, trading as Evaluated Applications. All rights reserved.

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.

NuGet packages (6)

Showing the top 5 NuGet packages that depend on EvaluatedApplications.EvalApp:

Package Downloads
EvaluatedApplications.HoloDb

An embeddable SQL database for .NET that builds its own fast access paths from the queries it runs, so there is no index to design. Tables are typed columns, held in memory or on disk, with ACID transactions over a write-ahead log, larger-than-memory paged storage whose every page is checksummed, and exact aggregates: integer sums are 128-bit and throw instead of wrapping. It is for applications that need dashboards, change feeds and analytics over their own data without running a database server, and it includes similarity search (NEAREST), NULL support, ClickBench-compatible analytic SQL, atomic change batches, and an EvalApp extension with async execution.

EvaluatedApplications.AlgFormer

The reusable phasor-model engine: define a model, train it data-parallel through an EvalApp pipeline, and run inference (predict / generate, with an optional KV cache). Ships two param-for-param comparable cores — AlgFormer (softmax dot-product attention) and HoloFormer (holographic-native attention: bind the key to its value, causally bundle into one superposed memory trace, then resonate the query to retrieve, O(T·d) with O(1) KV-serve) — plus HoloShape capacity-law sizing, compute-equivalent (invisible-param) accounting, a built-in subword tokenizer, checkpoint save/load with in-place growth (context/shifts/vocabulary), and MQTT-based distributed/swarm training. HoloFormer also ships an opt-in, default-OFF per-layer Adam gate ("ADAM & EVE") that nudges a layer's update toward a codebook-derived target norm range; default off and byte-identical when unused, but live in production via the GPU-fused trainer (see AlgFormer.Gpu) since 2026-09-24. Pure double[] + SIMD, no external ML runtime. Built on the Phasor VSA codec and the EvalApp pipeline runtime.

EvaluatedApplications.AlgFormer.Gpu

ILGPU/CUDA acceleration for the AlgFormer package's HoloFormer model: GpuHoloTrainer runs batched GPU forward/backward training (CPU Adam by default, with an opt-in fused on-GPU Adam step) and GPU inference (Logits, StackIterLogits). GpuDevice auto-detects a CUDA device at startup and otherwise runs the SAME kernels on ILGPU's own CPU accelerator, so one build works with or without a CUDA card; genuinely over-length windows still run on the CPU HoloFormer directly. Every kernel is gradchecked against the CPU HoloFormer as its correctness oracle (float-close fp32, not bit-identical). Also ships an opt-in, default-OFF "ADAM & EVE" per-layer directional gradient gate layered on the fused on-GPU Adam step, live in production training (not an unused experiment) since 2026-09-24, plus preference (DPO) training on the GPU: per-sequence log-probabilities (SequenceLogProb) and a signed-weight all-positions training step (TrainBatchAllPosWeighted), at the same weight-tied depth the trainer trains at, with a frozen reference model (NewReference); exact bit-identical GPU page transforms for Phasor's Pleroma key band (PleromaPageTransform: Forward, Inverse, Dft over a 61-bit or sub-40-bit prime field, with an ILGPU CPU and a managed backend) and the Pleroma Cascade Newton pass (PleromaNewtonGpu); and a deprecated non-holo GpuModel/GpuTrainer twin for the older softmax-attention AlgFormer core. Add this package only if you want GPU acceleration; the core AlgFormer package stays dependency-free and AOT/trim-safe without it.

EvaluatedApplications.Tracer

Fast pathfinding and game-AI SDK for real-time simulations with many moving agents. Bakes triangle navmeshes (flat, hierarchical, or multi-layer for ground/air/water) and routes single agents or whole goal-sharing swarms across them, reacts to dynamic obstacles without a full re-bake, and adds line-of-sight, fog-of-war, influence maps, congestion analysis, and spatial-hashed combat engagement for AI perception and tactics. Includes composable steering behaviors (Seek, Wander, Patrol, Flee, Queue, FlowLane) via a fluent Navigator, load-aware adaptive clustering for large populations, and a separate bake-free grid-tactics lane (tile line-of-sight, movement range, cover, target selection) for tile/square-grid games. Built on the EvalApp pipeline runtime for parallel, adaptively-tuned execution.

EvaluatedApplications.Prose

Grammar-driven synthetic corpus generator: mines nouns, verbs and adjectives and the syntax that joins them from real text with the rules of grammar, stores them in HoloDb, and recombines them into new grammatical sentences built only from words and pairings the text used, plus reading-comprehension Q&A pairs (single-sentence, multi-sentence passage, and two-turn). Rules-first (deterministic, zero-training) part-of-speech tagging, chunking and role assignment; mining fans out across cores through EvalApp (generation does too, opt-in); an optional mini-HoloFormer scorer re-ranks generated candidates by next-lemma likelihood once trained (POS tagging stays 100% rules-based).

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
6.0.2 61 11/10/2026
6.0.1 148 11/10/2026
6.0.0 58 11/10/2026
5.0.1 97 11/9/2026
5.0.0 127 11/9/2026
4.4.2 225 10/19/2026
4.4.1 375 10/9/2026
4.4.0 151 10/6/2026
4.3.1 120 10/5/2026
4.3.0 95 10/5/2026
4.2.1 98 10/5/2026
4.2.0 149 10/3/2026
4.1.2 105 10/3/2026
4.1.1 103 10/3/2026
4.1.0 117 10/3/2026
4.0.0 141 10/3/2026
3.1.0 102 10/3/2026
3.0.0 194 10/3/2026
2.0.0 484 10/2/2026
1.9.0 243 10/2/2026
Loading failed