EvaluatedApplications.EvalApp 6.0.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package EvaluatedApplications.EvalApp --version 6.0.0
                    
NuGet\Install-Package EvaluatedApplications.EvalApp -Version 6.0.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="6.0.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="EvaluatedApplications.EvalApp" Version="6.0.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 6.0.0
                    
#r "nuget: EvaluatedApplications.EvalApp, 6.0.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@6.0.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=6.0.0
                    
Install as a Cake Addin
#tool nuget:?package=EvaluatedApplications.EvalApp&version=6.0.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. Its headline feature is composition: a compiled pipeline is a value, so you build a program out of pipelines that run each other, sit in an object's context, ride in your task data and recurse (or call each other in a circle) through PipelineRef; see section 20. 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, the key types, 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
  20. Composing pipelines: the headline feature (in a context, in task data, as a step, recursive and circular)
  21. Exclusive resources: the domain-level safety harness
  22. One-thread hosts: Blazor WebAssembly, one-core containers, pinned processes

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();

// 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() then finalizes the whole app. Rebuild once at startup — rebuilding per request throws away all accumulated tuner state (see Anti-patterns).

Next, the feature to learn first: pipelines compose. The pipeline above is an ordinary value. Hold it in a context object, carry it in a task record, run it as a step of another pipeline with .AddStep("Inner", pipeline), or let it call itself or another pipeline through a PipelineRef: that is how layers, loops and recursion are written, with no orchestration code around RunAsync. Runnable examples are at the top of section 20.


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.
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) — an instance matches both the IStep<T> and AsyncStep<T> overloads, so the call is ambiguous and fails to compile (CS0121), 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.

What the default merge copies. It builds a NEW T from the original plus each branch's changes (the group hands the next step that new instance; the original is not mutated from the caller's point of view), and it carries every public read-write property and every public non-readonly field. State that cannot be set (get-only properties, readonly fields, private members) is not carried, so a type with nothing mergeable (for example a bare int, or a type whose state is all get-only) is refused where you declare the group (InvalidOperationException naming the type) instead of silently producing an empty copy. Give such a type settable state or pass your own IMergeStrategy<T>.

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: same as `parallelism: Tunable.ForItems()` (bound = 2 x cores, adaptive dispatch decides per
//     call; items are independent, results merge back in SOURCE order on EVERY path: sequential, fused-inline and
//     parallel). Pass `maxParallelism: 1` for strictly sequential. `.OnItemCompleted((item, result) => ...)` is called
//     on every path and is the per-item progress hook (see §22 for a ForEach that runs once per run on a one-thread host).
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.

The threshold beats the declared bound. With fewer items than MinItemsForParallel a ForEach runs inline on the calling thread whatever maxParallelism/Tunable.ForItems(n) says: 3 items x 100 ms with maxParallelism: 8 take ~300 ms, not ~100 ms (the item count alone cannot tell cheap items from heavy ones). The bound caps a fan-out; it never forces one. For a few HEAVY items, lower the threshold with the minItemsForParallel overload, e.g. parallelism: Tunable.ForItems(8), minItemsForParallel: Tunable.InlineBelow(1) (or InlineBelow(2) to keep a single item inline). maxParallelism: 1 is always strictly sequential. pipeline.Report() shows which happened (Inline vs FanOut per ForEach node).

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.

The same overloads inside an If branch and inside a sub-task body. IConditionalBranchBuilder<T> (the then/@else builders) and ISubTaskBuilder<T> take ForEach with the default bound, int maxParallelism, int maxParallelism + ForEachFailureMode, and (branch builder) TunableConfig + ForEachFailureMode, exactly as the top-level builder does: b.ForEach(select, merge, "Items", configure) needs no parallelism: argument and gets Tunable.ForItems().

Releasing a large payload mid-run. The engine no longer keeps a step's input in its own async state once the next step has it: inside a sequence, inside an If branch (then and else), and for the run's own input. Verified by tests in both Debug and Release when a step in the branch genuinely suspends. Not guaranteed: a Gate, Exclusive or Pressure body (a payload dropped inside one was still reachable in a Release build, cause not found), and the limits below, all measured:

  • The parent value a node needs afterwards. AddSubTaskFor, ForEach and AddParallelGroup call merge(parent, inner) when they finish, so the PARENT T stays reachable until that node ends (a saga likewise keeps its starting snapshot). To free a big object inside one of those, create it inside the body.
  • A body that never suspends. If every step from the one that dropped the payload to the point you look runs synchronously (no real await), the CLR's own stack temporaries can still root the earlier value until the nested node returns. A step that genuinely awaits (file or network I/O, Task.Yield) lets the stack unwind and the value is collected. When neither fits, hold the big payload behind a small mutable holder that the releasing step clears (holder.Rows = null); that is deterministic everywhere.

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

Adaptive dispatch (on by default for every ForEach). Whether a call actually fans out is decided from the MEASURED per-item cost of that ForEach against the learned dispatch overhead (about 70 us per fan-out on the dev machine), not from a fixed item count. The first calls fan out and time their items (safe for heavy or rendezvous-style items); once the cost is known, a cheap-item call runs inline on the caller with no thread-pool wake, a heavy-item call fans out, and the cost is re-sampled so a node that gets heavier is noticed. MEASURED: a pre-built ForEach over 4-64 trivial items cost 72-84 us per call when it always fanned out, and now costs the same as its sequential form (0.7-7 us); at 1024 trivial items it matches sequential instead of losing to it. maxParallelism and minItemsForParallel are still hard bounds on top of this. A ForEach is still a pipeline step (~0.6 us fixed plus ~50 ns per item): for a tight cheap loop use ChunkedWork.

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

``ChunkedWorkis **adaptive**: it learns the per-item cost of each call site (the first calls probe one item inline) and fans out across workers only when the work saved beats the measured dispatch overhead; otherwise the whole range runs inline on the caller at about the cost of a delegate call. So never write a small-N bypass in front of it; pass every size through it. Because an inline run is one chunk and a probe is its own chunk, the number of partials returned varies: reduce them associatively (a sum, a max, a merge).chunkCountis an upper bound on workers (defaultEnvironment.ProcessorCount). There is no cancellation token, no middleware, no StepContext— that overhead is exactly what this primitive exists to avoid. It does not replaceForEach` for I/O-bound or resource-gated per-item work; it exists for the opposite shape.

For the very common map into an output array shape there is an allocation-free form, ChunkedWork.For(count, body), where body(i) runs exactly once for every i in [0, count) (bodies must write disjoint state). The call site is identified by the compiler (file and line), so each site learns its own cost:

using EvalApp;

var data = Enumerable.Range(0, 1_000_000).ToArray();
int chunks = Environment.ProcessorCount, per = (data.Length + chunks - 1) / chunks;
var counts = new long[chunks];

// Call it for EVERY size, no hand-written small-N bypass: EvalApp times this call site and runs it inline
// whenever fanning out would cost more than it saves, and fans out when the work is big enough.
ChunkedWork.For(chunks, c =>
{
    int lo = c * per, hi = Math.Min(data.Length, lo + per);
    long n = 0;
    for (int i = lo; i < hi; i++) if (data[i] % 7 == 0) n++;
    counts[c] = n;                      // disjoint write: each chunk owns its slot
});

Console.WriteLine(counts.Sum());        // 142858

MEASURED (2026-10-03, this machine): one call at 256 rows split into 16 chunks costs about the same as the hand loop (1.09 us vs 1.07 us); at 2M rows it fans out (0.70 ms vs 3.8 ms). The old pipeline-per-call route cost 1.7-29 us for the same tiny chunk counts.


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 only when the step actually has to queue: it finds no free permit at the moment it reaches the gate. It fires once for that step, with the step's data, before the step starts waiting; a step that gets a permit straight away never fires it, however many times the pipeline runs. 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).

A gate's bound can change while runs are in flight (the tuner resizes it, and many concurrent RunAsync calls may be queued on it): a larger bound admits queued steps at once, a smaller bound lets running steps finish and makes new arrivals wait until enough permits are free. Queued steps are admitted in arrival order.


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.

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

Where an exception comes out (pinned by ExceptionSurfaceTests). The same exception is reported three ways, always as the ORIGINAL exception object (message intact; trimming/AOT only reduces stack frames):

What throws Run directly: pipeline.RunAsync Run as a step: AddStep("name", pipeline) in an outer pipeline
a step (any kind) returns Failure carrying the exception the outer run returns Failure carrying the same exception
middleware (outside next) RunAsync throws the exception the outer run returns Failure carrying the exception

So a caller of a top-level pipeline that has middleware handles both surfaces: try { var r = await p.RunAsync(x, ct); if (r.IsFailure) { /* r is Failure: .Exception */ } } catch (Exception e) { /* middleware threw */ }. Cancellation has its own two surfaces (see the cancellation contract in the repo's docs/cross-project-guidance.md). A middleware that wants every error as a Failure wraps its own body in a try/catch and returns new PipelineResult<T>.Failure(data, ex).

Middleware and ForEach items. A task-level .WithMiddleware(...) is typed on the OUTER T, so it wraps the outer steps and never sees an item. To wrap the steps of a ForEach item pipeline (or an AddSubTaskFor body), register an IStepMiddleware<TItem> on that sub-builder, inside configure:

using EvalApp;

Eval.App("ItemMiddleware")
    .DefineDomain("Orders")
        .DefineTask<Basket>("Price")
            .ForEach(select: d => d.Lines, merge: (d, lines) => d with { Lines = lines.ToList() }, collectionName: "Lines",
                configure: sub => sub
                    .WithMiddleware(new LineTiming())   // typed on the ITEM; runs around every item step
                    .AddStep("Calc", (Line l) => l with { Total = l.Price * l.Qty }))
        .Run(out ICompiledPipeline<Basket> pipeline)
    .Build();

public sealed record Line(decimal Price, int Qty, decimal Total = 0m);
public sealed record Basket(List<Line> Lines);

public sealed class LineTiming : IStepMiddleware<Line>
{
    public async ValueTask<PipelineResult<Line>> ExecuteAsync(
        Line data, string stepName, Func<Line, CancellationToken, ValueTask<PipelineResult<Line>>> next, CancellationToken ct = default)
    {
        var result = await next(data, ct);
        Console.WriteLine($"item step '{stepName}' done");
        return result;
    }
}

Item middleware turns the fast path off. A ForEach whose item pipeline is exactly one synchronous pure step (AddStep(name, Func<TItem,TItem>)) with no middleware runs as one fused loop with no per-item overhead (Inline mode, visible in pipeline.Report()). Attaching ANY middleware to that item pipeline disables the fused path and the node runs the per-item delegate path, so wrapping a hot loop this way changes what you measure. For a cheap fused loop, time inside the step with your own closure (Func<TItem,TItem> that records a stopwatch) and keep the middleware for heavy items. When comparing two arms, attach the observer to both or to neither.


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. See License at the bottom of this page for the commercial terms. The types below are an offline key verifier and the key format that every Evaluated Applications product shares. They report what a key says; EvalApp itself never limits anything based on the result. They live in the root EvalApp namespace.

The key types, for tooling that reads or writes the same format:

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

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,
  "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 DefineTask name) and RunAsync(data, ct). There is no step tree to walk and no diagram or export API. The one read-only window is pipeline.Report() (a PipelineReport: plan nodes plus, per ForEach, inline vs fanned-out execution counts) and ExecutionHost.Yields; see §22.


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.
  • A hand-written loop or recursion around RunAsync (an outer while, a service method that calls the pipeline per batch) instead of a pipeline that runs pipelines: the loop is then invisible to gates and the tuner. Layer and recurse through context instead (§20).
  • Guarding a non-thread-safe singleton with a comment ("never two GPU branches") instead of an ExclusiveResource (§21).
  • Assuming a pipeline with middleware never throws from RunAsync: middleware exceptions throw at the top level and are a Failure only when nested (§11).

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; awaitable ForAsync/RunAsync, §22).
  • Host mode — ExecutionHost (IsSingleThreaded, Reason, OverrideSingleThreaded, MaxConcurrency, YieldBudget) and IAppBuilder.WithSingleThread (§22).
  • Composition and safety — PipelineRef<T>, PipelineTrace/PipelineFrame/PipelineRecursionException (§20); ExclusiveResource, ExclusivePolicy, ExclusiveViolationException, .Exclusive(...), .Run(PipelineRef<T>) (§21).
  • Licensing — LicenseTier, LicenseClaims, License, LicenseVerifier, LicensePayload, Base64Url.

Everything else — every internal engine namespace (EvalAppEngine.*: Fluent, Core, Abstractions, Steps, Middleware, Extensions, Compatibility, Diagnostics, Parallel, Tuning, 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. The key types (License/LicenseVerifier/LicenseTier/etc.) live in the root EvalApp namespace: change the using, keep the code.

4. If you used [InternalsVisibleTo] trusted-first-party seams (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.

20. Composing pipelines: the headline feature <a name="composing"></a>

A compiled pipeline is an ordinary value: ICompiledPipeline<T> has one method, RunAsync(data, ct). That makes pipelines composable with no new concept: a step may run another pipeline, a pipeline may run itself, and a pipeline can be held in a GlobalContext/DomainContext so a higher level orchestrates lower-level ones. Layer by pipeline-in-pipeline instead of writing a loop around RunAsync; the loop is a pipeline, so the gates, the tuner and the safety harness see it.

Steps stay decoupled because they share only their data and the context they are handed. Circular references are part of the design, not a hazard: a context that holds a reference to a pipeline whose steps read that same context, or two pipelines that call each other, need nothing special.

Forms of one idea (a pipeline is a value). Each numbered snippet below is a complete program, compiled and run.

Form How Shown in
Object as context the DomainContext (or GlobalContext) object you write owns its pipelines; steps read them back from it snippet 1
Pipeline in the task data the record carries an ICompiledPipeline<T>, so each job picks its pipeline snippet 2
Mutual recursion two pipelines call each other through PipelineRefs; an If ends the loop snippet 3
Several extensions, one context the global context is your own object and holds every service (a database service, a world, other pipelines) snippet 4
Pipeline in the context, layered an outer schedule pipeline runs an inner one held in GlobalContext, ct flowing down "Layers" below
Pipeline as a step, self-recursion .AddStep(name, pipeline); a pipeline that calls itself from a ForEach "A pipeline as a step", "Recursion" below

Snippet 1: an object is the context, and it owns the pipelines.

using EvalApp;

// An object IS the context. It holds the pipelines it owns, and a step reads them back from it.
var shop = new Shop();

Eval.App("Shop")
    .DefineDomain("Orders", shop)
        .DefineTask<Order>("Price")
            .AddStep("Base", o => o with { Total = o.Qty * 10m })
        .Run(shop.Price)                              // bind the ref the object already holds
        .DefineTask<Order>("Checkout")
            .AddStep("Price", shop.Price)             // the whole Price pipeline is one step here
            .AddStep<ApplyDiscount>("Discount")       // a context step that reads the same object
        .Run(out ICompiledPipeline<Order> checkout)
    .Build();

var r = await checkout.RunAsync(new Order(Qty: 3));
Console.WriteLine($"total = {r.GetData().Total}");     // 3 x 10 = 30, minus 10% = 27

public sealed record Order(int Qty, decimal Total = 0m);

public sealed class Shop : DomainContext
{
    public PipelineRef<Order> Price { get; } = new("Price");
    public decimal DiscountRate { get; init; } = 0.10m;
}

public sealed class ApplyDiscount : ContextPureStep<NullGlobalContext, Shop, Order>
{
    protected override ValueTask<Order> TransformAsync(
        Order o, NullGlobalContext global, Shop shop, CancellationToken ct)
        => ValueTask.FromResult(o with { Total = o.Total * (1 - shop.DiscountRate) });
}

Snippet 2: the task data carries the pipeline. The data decides which pipeline runs; the step passes ct down and treats the inner Failure as its own.

using EvalApp;

// The task data carries the pipeline to run, so each job chooses its own.
ICompiledPipeline<Job> shout = null!, whisper = null!;

Eval.App("Carried")
    .WithContext(new NoGlobal())
    .DefineDomain("Text")
        .DefineTask<Job>("Shout").AddStep("Up", j => j with { Text = j.Text.ToUpperInvariant() }).Run(out shout)
        .DefineTask<Job>("Whisper").AddStep("Down", j => j with { Text = j.Text.ToLowerInvariant() }).Run(out whisper)
        .DefineTask<Job>("Dispatch")
            .AddStep("RunCarried", async (Job j, CancellationToken ct) =>
            {
                if (j.Next is null) return j;
                var r = await j.Next.RunAsync(j with { Next = null }, ct);    // ct flows down
                return r is PipelineResult<Job>.Failure f ? throw f.Exception : r.GetData();
            })
        .Run(out ICompiledPipeline<Job> dispatch)
    .Build();

foreach (var job in new[] { new Job("Hello", shout), new Job("World", whisper), new Job("Plain") })
    Console.WriteLine((await dispatch.RunAsync(job)).GetData().Text);    // HELLO, world, Plain

sealed class NoGlobal : GlobalContext { }
public sealed record Job(string Text, ICompiledPipeline<Job>? Next = null);

Snippet 3: two pipelines that call each other (a circular reference).

using EvalApp;

// Two pipelines that call each other: a circular reference, tolerated. The If ends it.
var even = new PipelineRef<Num>("Even");
var odd = new PipelineRef<Num>("Odd");

Eval.App("Parity")
    .WithContext(new NoGlobal())
    .DefineDomain("Math")
        .DefineTask<Num>("Even")
            .If(n => n.Value == 0, then: b => b.AddStep("Yes", n => n with { IsEven = true }))
            .If(n => n.Value > 0, then: b => b
                .AddStep("Down", n => n with { Value = n.Value - 1 })
                .AddStep("AskOdd", odd))              // Even runs Odd ...
        .Run(even)
        .DefineTask<Num>("Odd")
            .If(n => n.Value == 0, then: b => b.AddStep("No", n => n with { IsEven = false }))
            .If(n => n.Value > 0, then: b => b
                .AddStep("Down", n => n with { Value = n.Value - 1 })
                .AddStep("AskEven", even))            // ... and Odd runs Even.
        .Run(odd)
    .Build();

foreach (var v in new[] { 0, 7, 10 })
    Console.WriteLine($"{v} even? {(await even.RunAsync(new Num(v))).GetData().IsEven}");   // True, False, True

sealed class NoGlobal : GlobalContext { }
public sealed record Num(int Value, bool? IsEven = null);

Snippet 4: several extensions, one context. WithContext takes one object, and that object is yours, so it can hold everything a step needs. An extension such as WithHoloDb(service) or WithTracer(world) is a shorthand that sets the context to its own object; when you use more than one, make your own context hold each of them (Db = new HoloDbContext(service), Tracer = new TracerContext(world)) instead of building one app per extension.

using EvalApp;

// Several extensions, one context: the global context is YOUR object, and it holds whatever services
// the steps need (a database service, a world, a clock, other pipelines). Nothing fights over a slot.
var services = new Services(new Database("orders-db"), new Clock());

Eval.App("Multi")
    .WithContext(services)
    .DefineDomain("Orders", new NullDomain())
        .DefineTask<Order>("Stamp")
            .AddStep<LoadAndStamp>("LoadAndStamp")
        .Run(out ICompiledPipeline<Order> pipeline)
    .Build();

var r = await pipeline.RunAsync(new Order(7));
Console.WriteLine(r.GetData().Note);      // orders-db row 7 at tick 1

public sealed record Order(int Id, string Note = "");
public sealed record Database(string Name);
public sealed class Clock { public int Ticks; public int Next() => Interlocked.Increment(ref Ticks); }

public sealed class Services(Database db, Clock clock) : GlobalContext
{
    public Database Db { get; } = db;
    public Clock Clock { get; } = clock;
}

public sealed class LoadAndStamp : ContextPureStep<Services, NullDomain, Order>
{
    protected override ValueTask<Order> TransformAsync(
        Order o, Services app, NullDomain domain, CancellationToken ct)
        => ValueTask.FromResult(o with { Note = $"{app.Db.Name} row {o.Id} at tick {app.Clock.Next()}" });
}

public sealed class NullDomain : DomainContext { }

The one trick is forward declaration. A context object is created before the pipelines exist, and a pipeline that recurses is referenced from inside its own steps, so you need a handle before the build:

  • PipelineRef<T>: create it first (new PipelineRef<T>("Name")), put it in a context or capture it in a lambda, bind it with .Run(ref). Calling it before it is bound throws a clear error naming it.
  • Or the plain form: declare ICompiledPipeline<T> self = null!; ahead of time and bind it with .Run(out self); lambdas capture the variable, and it is assigned before the first RunAsync.

Recursion: a pipeline that calls itself, fanned out by ForEach.

using EvalApp;

// A directory tree summed by a pipeline that calls ITSELF once per child directory.
ICompiledPipeline<Dir> self = null!;      // declared ahead of time; Run(out self) fills it in

Eval.App("TreeSum")
    .WithContext(new NoGlobal())          // compiled path (the production shape)
    .DefineDomain("Tree")
        .DefineTask<Dir>("SumDir")
            .ForEach(
                select:  d => d.Children,
                merge:   (d, kids) => d with { Children = kids },
                collectionName: "Children",
                configure: sub => sub.AddStep("Recurse", async (Dir child, CancellationToken ct) =>
                {
                    var r = await self.RunAsync(child, ct);          // same pipeline, one level down
                    return r is PipelineResult<Dir>.Failure f ? throw f.Exception : r.GetData();
                }))
            .AddStep("Total", d => d with { Total = d.Size + d.Children.Sum(c => c.Total) })
        .Run(out self)                    // bind: from here `self` is the compiled pipeline
    .Build();

var tree = new Dir("root", 1, [
    new Dir("src", 10, [new Dir("a", 100, []), new Dir("b", 200, [])]),
    new Dir("docs", 20, [])]);

var result = await self.RunAsync(tree);
Console.WriteLine($"total = {result.GetData().Total}");      // 1 + (10 + 100 + 200) + 20 = 331

sealed class NoGlobal : GlobalContext { }
record Dir(string Name, long Size, IReadOnlyList<Dir> Children, long Total = 0);

Layers: an outer schedule pipeline drives an inner per-batch pipeline held in the context. Both are declared in one domain; the outer step reads the inner pipeline from GlobalContext (the one sanctioned shared seam, rubric principle 3) and loops it until a condition holds. Pass the step's ct down so cancelling the outer run cancels the inner one.

using EvalApp;

// Context object FIRST: it already holds a PipelineRef to the inner pipeline, which does not exist yet.
var ctx = new TrainContext();
var schedule = new PipelineRef<Batch>("Schedule");

Eval.App("Train")
    .WithContext(ctx)
    .DefineDomain("Train", new TrainDomain())
        // inner level: one optimiser step on one batch
        .DefineTask<Batch>("BatchStep")
            .AddStep("Descend", b => b with { Loss = Math.Max(0, b.Loss - 3), Epoch = b.Epoch + 1 })
        .Run(ctx.BatchStep)               // bind the ref held in the context
        // outer level: the schedule drives the inner pipeline until the loss reaches zero
        .DefineTask<Batch>("Schedule")
            .AddStep<RunUntilConverged>("RunUntilConverged")
        .Run(schedule)
    .Build();

var done = (await schedule.RunAsync(new Batch(Loss: 10, Epoch: 0))).GetData();
Console.WriteLine($"loss={done.Loss} epochs={done.Epoch} innerRuns={ctx.InnerRuns}");   // loss=0 epochs=4 innerRuns=4

record Batch(int Loss, int Epoch);
sealed class TrainDomain : DomainContext { }

sealed class TrainContext : GlobalContext
{
    public PipelineRef<Batch> BatchStep { get; } = new("BatchStep");
    public int InnerRuns;
}

sealed class RunUntilConverged : ContextSideEffectStep<TrainContext, TrainDomain, Batch>
{
    protected override async ValueTask<Batch> ExecuteAsync(
        Batch b, TrainContext global, TrainDomain domain, CancellationToken ct)
    {
        while (b.Loss > 0)
        {
            var r = await global.BatchStep.RunAsync(b, ct);   // ct flows down: cancelling the outer run cancels the inner
            if (r is PipelineResult<Batch>.Failure f) throw f.Exception;
            b = r.GetData();
            global.InnerRuns++;
        }
        return b;
    }
}

What composes cleanly, measured by EvalApp.Tests/Core/PipelineCompositionTests.cs:

  • Re-entrancy. Every RunAsync rents its own context, so a pipeline running inside itself (or two pipelines calling each other) is safe. Resource Gates are re-entrant for the same flow: a nested run that reaches a Gate its own caller already holds (recursion through a capacity-1 gate) runs inside that permit instead of waiting on it forever. This applies when the pipeline holding the gate composes: it is bound to a PipelineRef (Run(ref)) or uses .AddStep(name, pipeline); a plain pipeline publishes nothing and pays nothing. It follows the flow, never the gate: two independent runs still queue, and work forked from inside the gate (a ForEach worker, a parallel branch) is not the holder and takes its own permit (so a fork that re-enters a capacity-1 gate its parent holds waits for it; cancel or restructure). ExclusiveResource is re-entrant the same way.
  • Cancellation propagates down because you pass the step's ct to the inner RunAsync. It then surfaces on either of the two documented surfaces (a Failure result or a thrown OperationCanceledException); the outer step turns it into a failure of its own step.
  • Failure does not throw across levels: an inner failure is a PipelineResult.Failure the outer step decides what to do with (rethrow it as the snippets do, or recover).
  • A pipeline as a step. When the outer step just means "run that pipeline and carry on", say so: .AddStep("Inner", innerPipeline) (on any task, sub-task, branch or saga builder). It runs the pipeline on the current data, hands its output to the next step, and turns an inner Failure into this step's failure with its original exception (type and stack kept), so the failure channel is never dropped by accident. The step does not keep its input while the inner run is in flight: its Failure carries the inner run's last state, and a thrown inner run gives a default Data. The plan reports the step as a Nested node, and the pipeline that uses it is framed in PipelineTrace. Pass a PipelineRef<T> for a pipeline that is built later or recurses; a plain variable is captured by value when the step is declared (a null! placeholder fails right there with a message saying so).
using EvalApp;

var inner = new PipelineRef<Batch>("Inner");

Eval.App("Layers")
    .WithContext(new NoGlobal())
    .DefineDomain("D")
        .DefineTask<Batch>("Inner")
            .AddStep("Descend", b => b with { Loss = b.Loss - 3 })
        .Run(inner)
        .DefineTask<Batch>("Outer")
            .AddStep("Warm", b => b with { Epoch = b.Epoch + 1 })
            .AddStep("RunInner", inner)                 // the whole inner pipeline is one step here
        .Run(out ICompiledPipeline<Batch> outer)
    .Build();

var r = await outer.RunAsync(new Batch(Loss: 10, Epoch: 0));
Console.WriteLine($"loss={r.GetData().Loss} epoch={r.GetData().Epoch}");   // loss=7 epoch=1

sealed class NoGlobal : GlobalContext { }
record Batch(int Loss, int Epoch);
  • Context flow. Each pipeline sees the WithContext global and its own domain's context. Pipelines defined in the same app share its WithResource gates (one semaphore per kind), so an outer level and an inner level contend on the same declared budget, which is what you want.
  • Tracing across levels. A named-step middleware only receives a step's name, so two pipelines that both have a step called "Score" look identical. The path is the PIPELINE chain only: inside the middleware, PipelineTrace.Current?.Path is "Outer > Inner" (one frame per pipeline run, never per step). The step name is the stepName argument the middleware already receives, so compose the two yourself: var where = $"{PipelineTrace.Current?.Path}/{stepName}"; // "Outer > Inner/Score". PipelineTrace.Depth and Current (a PipelineFrame with Name, Parent, Depth, Path) give the nesting depth and the caller chain. Tracking is off unless needed: only a pipeline bound to a PipelineRef (and anything running inside one) records its call chain, so an ordinary top-level pipeline pays nothing. Bind the pipelines you compose with .Run(ref); a pipeline that recurses through a bare Run(out self) variable is not tracked (no path, no depth guard).
  • Runaway recursion is a failure, not a stack overflow: a run nested deeper than PipelineTrace.MaxDepth (default 256) fails with PipelineRecursionException. A recursive pipeline needs an If that stops it.
  • Tuning. WithTuning observes each run as one sample; an outer run that contains the inner runs is timed as one long run next to the short inner ones. Do not attach adaptive tuning to a pipeline that recurses into itself and expect clean per-level numbers.

21. Exclusive resources: the domain-level safety harness <a name="exclusive"></a>

Some things must never be touched by two flows at once: a GPU Accelerator singleton, a native handle, a single-writer file. "Never two concurrent GPU branches" should be a declaration the runtime enforces, not a comment. Declare an ExclusiveResource once, next to the thing it protects, then guard the places that touch it:

  • .Exclusive(resource, g => g.AddStep(...)): a scope on any task or sub-task builder.
  • .WithMiddleware(resource.Guard<T>("StepA", "StepB")): guards named steps (all steps if none are named). This is how to protect AddParallelGroup branches, which have no scope to wrap. Build() fails, naming the resource, if a name matches no step in any pipeline the guard instance is attached to, so renaming a step can never silently un-guard it. One guard instance may serve several tasks (each task owns a different subset of the names); a name that exists in none of them is the error.

Policy ExclusivePolicy.Fail (default) refuses a concurrent second entry: the intruder's step fails with ExclusiveViolationException (naming the resource, the holder, the intruder and how long it was held) and the holder is untouched. ExclusivePolicy.Queue serialises instead (the wait honours the token). Entry is re-entrant for the same async flow, so recursion and an inner pipeline run from a guarded step are not mistaken for a second instance.

using EvalApp;

// Declared ONCE, next to the thing it protects (a non-thread-safe singleton such as a GPU accelerator).
var gpu = new ExclusiveResource("gpu", ExclusivePolicy.Fail);

Eval.App("TrainBatchCoop")
    .WithContext(new NoGlobal())
    .DefineDomain("Train")
        .DefineTask<Work>("Coop")
            // Branches have no scope to wrap, so guard them BY NAME with middleware.
            .WithMiddleware(gpu.Guard<Work>("GpuForward", "GpuBackward"))
            .AddParallelGroup(pg => pg
                .AddBranch("GpuForward",  w => { Thread.Sleep(100); return w; })
                .AddBranch("GpuBackward", w => { Thread.Sleep(100); return w; }))   // BUG: two GPU branches
        .Run(out ICompiledPipeline<Work> pipeline)
    .Build();

var result = await pipeline.RunAsync(new Work(1));
if (result is PipelineResult<Work>.Failure { Exception: var ex })
{
    var violation = (ex as AggregateException)?.InnerException as ExclusiveViolationException
                    ?? (ExclusiveViolationException)ex;
    Console.WriteLine($"caught: {violation.ResourceName} held by '{violation.Holder}', intruder '{violation.Intruder}'");
    Console.WriteLine($"violations counted: {gpu.ViolationCount}");
}

// The same resource as a SCOPE around steps that run one after another inside ONE pipeline:
var queue = new ExclusiveResource("single-writer", ExclusivePolicy.Queue);   // serialise instead of failing
Eval.App("Writer")
    .WithContext(new NoGlobal())
    .DefineDomain("IO")
        .DefineTask<Work>("Write")
            .Exclusive(queue, g => g.AddStep("Append", w => { Thread.Sleep(20); return w; }))
        .Run(out ICompiledPipeline<Work> writer)
    .Build();

var runs = await Task.WhenAll(Enumerable.Range(0, 4).Select(i => Task.Run(() => writer.RunAsync(new Work(i)).AsTask())));
Console.WriteLine($"queued writers all succeeded: {runs.All(r => r.IsSuccess)}");

sealed class NoGlobal : GlobalContext { }
record Work(int Id);

Rules worth knowing: work forked from inside a held scope (a ForEach/AddParallelGroup nested in it, or an inner pipeline's branches) does NOT silently inherit the hold: a fork that enters the resource again competes with its sibling forks (Fail refuses the second concurrent fork, Queue serialises them), while items run one at a time on the same flow stay re-entrant. A cheap ForEach that adaptive dispatch runs inline is not forked, so it never overlaps itself. The resource is per object, not per app: share the static readonly instance across every pipeline that touches the protected thing. Measured no-contention cost (2026-10-03, this machine, Release, after the scope chain): about +160-200 ns and +152 B per scope entry, +180-280 ns per guarded step over a no-op middleware.


22. One-thread hosts: Blazor WebAssembly, one-core containers, pinned processes <a name="one-thread"></a>

Fanning out only pays when there is a second thread to fan out to. On a host with ONE thread of execution a Task.Run is pure overhead, and a long synchronous run freezes whatever UI shares that thread. EvalApp detects this once, at startup, and switches mode. ExecutionHost.IsSingleThreaded is true when ANY of these holds (ExecutionHost.Reason says which): the code is running in a browser (Blazor WebAssembly has no worker threads); Environment.ProcessorCount is 1 (one core, a CPU affinity of one, a container CPU limit of one CPU, or DOTNET_PROCESSOR_COUNT=1); the process's affinity mask has one CPU; ExecutionHost.MaxConcurrency is 1; or you forced it with ExecutionHost.OverrideSingleThreaded(true) / the EVALAPP_SINGLE_THREAD=1 environment variable (code beats the variable; false/0 forces multi-threaded mode). A pipeline can pin its own mode with Eval.App(..).WithSingleThread(true|false) before DefineDomain.

In single-thread mode:

  • ForEach, AddParallelGroup and ChunkedWork.For/Run run inline on the caller: no Task.Run, no probing fan-out, no adaptive learning. Results are identical to the multi-threaded path.
  • Long runs yield cooperatively. A pipeline hands the thread back (to the SynchronizationContext, which in a browser lets the page repaint) between steps, between ForEach items, between parallel-group branches, and at the start of a run, whenever ExecutionHost.YieldBudget (default 12 ms; TimeSpan.Zero turns yielding off) of work has passed. The token is checked at each yield; cancellation behaves as it does everywhere else.
  • A synchronous call cannot yield: ChunkedWork.For/Run hold the thread until they return. Use the awaitable forms, ChunkedWork.ForAsync and ChunkedWork.RunAsync, anywhere a UI shares the thread; they work in time slices sized from the measured cost per item and throw OperationCanceledException at a yield once the token is cancelled. On a multi-threaded host they are the ordinary adaptive fan-out and add nothing.

On a multi-threaded host none of this is active (one bool test per run).

using EvalApp;

Console.WriteLine($"single-threaded host: {ExecutionHost.IsSingleThreaded} ({ExecutionHost.Reason})");
ExecutionHost.YieldBudget = TimeSpan.FromMilliseconds(8);   // optional: yield more often than the 12 ms default

// A pipeline pinned to the one-thread mode (normally you leave this alone and let the host be detected).
Eval.App("Scan").WithSingleThread(true)
    .DefineDomain("D")
        .DefineTask<Batch>("T")
            .ForEach(
                select: b => b.Items,
                merge: (b, xs) => b with { Total = xs.Sum(x => (long)x) },
                collectionName: "Items",
                maxParallelism: 8,
                configure: sub => sub.AddStep("Square", (int x) => x * x))
        .Run(out ICompiledPipeline<Batch> pipeline)
    .Build();

var result = await pipeline.RunAsync(new Batch(Enumerable.Range(0, 1000).ToArray()));
Console.WriteLine($"total: {result.GetData().Total}");

// The awaitable ChunkedWork keeps a UI thread responsive; cancel it like any other work.
using var cts = new CancellationTokenSource();
var squares = new long[200_000];
await ChunkedWork.ForAsync(squares.Length, i => squares[i] = (long)i * i, cts.Token);
Console.WriteLine($"last square: {squares[^1]}");

record Batch(int[] Items, long Total = 0);

Measured (2026-10-03, this machine, Release, a one-thread pump simulating a UI thread): ~120 ms of chunked work holds the thread for the full 120 ms with the synchronous ChunkedWork.For, and for at most 17 ms between UI turns with ChunkedWork.ForAsync; ForEach in single-thread mode peaks at 13 ms. Cheap ForEach calls run at the same speed as an inline multi-threaded call (about 250 ns at N=4, 500 ns at N=64). A single very long step (one sync step that itself runs for seconds) cannot be sliced by the runtime: split it into smaller steps or items, or poll the token inside it.

Where the yields happen, nested pipelines included. The time slice is ONE process-wide clock, so a nested pipeline (AddStep("Audit", inner), AddSubTaskFor, a PipelineRef) is sliced exactly like a top-level one: the check runs at the start of every RunAsync (the inner pipeline's too), between every node of every sequence (the inner pipeline's steps included), between ForEach items (when the items do not run as one fused inline loop) and between parallel-group branches. A yield is taken only when the slice is spent, and only at one of those points. The worst turn is therefore the longest stretch with no such point in it: one step, one sync ForEach fused loop, one ChunkedWork.For, or one callback. Nesting adds no stall of its own, and a nested pipeline of one big step is as unsliceable as a flat one. To shorten a turn, split the long step into several steps, or into ForEach items (each short), or loop with ChunkedWork.ForAsync.

Measuring it. ExecutionHost.Yields counts every yield this process has taken (read before and after a run, subtract). pipeline.Report() (extension, using EvalApp;) returns a PipelineReport: every plan node in pre-order, and for each ForEach how many executions ran inline (Inline), fanned out (FanOut) and the learned CostNs per item. A nested pipeline appears as one Nested node; take the inner pipeline's own report for its nodes. Counters accumulate, so subtract a "before" report. They are statistics, not accounting (benign races).

using EvalApp;

var before = ExecutionHost.Yields;
await pipeline.RunAsync(input);                        // any ICompiledPipeline<T> built by EvalApp
Console.WriteLine($"yields taken: {ExecutionHost.Yields - before}");
foreach (var n in pipeline.Report().Of("ForEach"))
    Console.WriteLine($"{n.Label}: {n.Calls} calls, {n.Inline} inline, {n.FanOut} fanned out");

A ForEach that runs once per run (a per-file or per-window loop). Results reach merge in INPUT order on every path. On a one-thread host the node never fans out, and the fused inline loop needs a learned per-item cost, so a node that executes only once per run always takes the per-item path: one awaited item pipeline plus one clock read per item, and a yield only when the slice is spent (tens of microseconds for 100 items; cheap enough to ignore). .OnItemCompleted((item, result) => ...) is the free per-item progress hook on that path and on the fused one.

Progress from inside a long synchronous step. A step is atomic to the runner: there is no hook inside it and no progress event. Two supported ways round it: (1) slice the loop, either as ForEach items or with await ChunkedWork.ForAsync/RunAsync inside an async step (these yield every YieldBudget and check the token at each yield); (2) call your own host callback from inside the step every N rows (in a browser worker a postMessage or JS-import call does not need the runtime to yield).

Cancelling a run: two levels. Cooperative: the token is checked at each yield point (between nodes and ForEach items, at ChunkedWork slice boundaries), never inside a running step; two conditions are the host's, not EvalApp's: the fused ForEach loop checks the token per item only when ct.CanBeCanceled, and a cancel REQUEST is only seen if the host's event loop actually runs a task between Task.Yield and its continuation (in a browser worker, whether a posted {cancel} message is delivered at the yield is the host's behaviour; measure it). Hard: the host kills the worker (worker.terminate()), which needs no cooperation and frees the module. A page that offers "cancel" for a run containing multi-second steps must use the hard one; cooperative cancel cannot land inside a 25 s library step.

Fatal runtime aborts are a host event, not a Failure. A managed OutOfMemoryException is caught and surfaces as a Failure. A fatal Mono GC abort ("Garbage collector could not allocate N bytes ...") ends the module and no managed code runs afterwards, so no pipeline or .NET watchdog can report it and there is no PipelineResult. Measured on an Edge worker in two modes: raised inside the synchronous call into runMain it is catchable by the host's try/catch; raised from an event-loop continuation (every abort after the first yield) it is silent. Both print the collector's text on the runtime's err stream. The host must watch err/onAbort, post "failed" at once, and keep a silence timer as the net for hangs:

// Host worker (JavaScript), a sketch to adapt to your loader (not part of EvalApp; the pipeline cannot do this for you).
// The point: hook the runtime's own error stream and abort callback, and report the instant either fires.
const fail = reason => postMessage({ type: 'failed', reason: String(reason) });
const { getAssemblyExports, runMain } = await dotnet
    .withModuleConfig({
        printErr: text => { if (text.includes('Garbage collector could not allocate')) fail(text); console.error(text); },
        onAbort: reason => fail(reason),
    })
    .create();
try { await runMain(); } catch (e) { fail(e); }   // the first (synchronous) abort mode lands here

The memory lever on Mono WebAssembly is the collector's own limit, not a forced collection. Measured (one customer workload): MONO_GC_PARAMS=soft-heap-limit=1700m completed 5 of 5 (Node) and 3 of 3 (Edge) at 3.3 to 3.5 GiB of linear memory instead of failing at the 4 GiB cap, at roughly twice the wall time; a forced GC.Collect() between units did not lower the peak (4 of 5 vs 1 of 5 completions, not significant). Caveat: there the live set was 1.5 GiB against a 1.7 GiB limit (a 7 to 13 percent margin), so the setting is per dataset and must be re-measured when the data grows. Also, the final linear-memory size is a ladder, not a smooth number (observed 46, 115, 138, 166, 199, 286, 344 MiB, consecutive ratio about 1.2, consistent with a x1.2 growth step; inferred, not checked in the binary): a run that ends one rung higher only crossed a threshold by any amount. Compare managed heap traces, never rungs, and size a probe one rung above the largest seen.

YieldBudget and what a pipeline retains. YieldBudget is a fixed wall-clock slice, not adaptive, so WHERE the yields land depends on machine speed and cold start, and where they land decides what is still reachable at a given instant (a node that finishes after a real suspension runs its continuation inline, so finished nodes' state can stay rooted until that chain unwinds). On a one-thread host, peak memory and weak-reference retention tests are therefore not deterministic unless you pin the budget: TimeSpan.Zero removes the pipeline's yields (not suspensions from real I/O such as a JS fetch); a one-tick budget yields at every check. Hold large payloads in a take-once box (Interlocked.Exchange) rather than in a record that travels the pipeline, so no node result carries them. Known holders that are not released: Gate/Exclusive/Pressure bodies keep their input; an all-sync If body; SubTask/ForEach/parallel nodes keep the PARENT value until the node ends (the merge needs it). The retention-test recipe is in the repo's docs/cross-project-guidance.md. Measured on CoreCLR; Mono WebAssembly scans the stack conservatively, so the live-set measurement there is the evidence.

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