Lazily 0.6.0

dotnet add package Lazily --version 0.6.0
                    
NuGet\Install-Package Lazily -Version 0.6.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="Lazily" Version="0.6.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Lazily" Version="0.6.0" />
                    
Directory.Packages.props
<PackageReference Include="Lazily" />
                    
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 Lazily --version 0.6.0
                    
#r "nuget: Lazily, 0.6.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 Lazily@0.6.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=Lazily&version=0.6.0
                    
Install as a Cake Addin
#tool nuget:?package=Lazily&version=0.6.0
                    
Install as a Cake Tool

lazily-cs

Lazy reactive primitives for C#/.NET — Source, Computed, and Effect with automatic dependency tracking, plus the lazily-spec wire protocol, CRDTs, and distributed plane.

This is the ninth binding in the lazily family. The reactive kernel is a direct port of the reference semantics, and it replays the shared cross-language conformance corpus rather than asserting behaviour it invented locally.

The Lazily.InteropPeer console project is the production-backed adapter for the cross-binding network suite. It advertises distributed_crdt with the JSON codec, routes semantic operations through CrdtPlaneRuntime and IpcWire, and leaves transport links unadvertised until executable channel adapters exist.

Status: active and spec-conformant. The binding replays all 125 canonical fixtures. Feature-specific peers remain staged until that execution flavor exists; the generated matrix below is the honest, single-source record of what can join each peer group.

Install

dotnet add package Lazily

Targets netstandard2.1, net8.0, and net10.0.

The optional net10-only R3 bridge is packaged separately:

dotnet add package Lazily.R3

See R3 adapter semantics for ownership, threading, errors, completion, batching, and the state-not-events boundary.

Quick start

using Lazily;

var ctx = new Context();

var celsius = ctx.Source(21.0);
var fahrenheit = ctx.Computed<double>(c => celsius.Get(c) * 9 / 5 + 32);

Console.WriteLine(fahrenheit.Get()); // 69.8 — computed on first read, then cached

using var log = ctx.Effect(c =>
{
    Console.WriteLine($"now {fahrenheit.Get(c):F1}F");
    return null; // an optional cleanup callback
});

celsius.Set(25.0); // the effect reruns; fahrenheit recomputes on demand

Latest-durable projection egress

LatestDurableProjectionCore<TKey, TValue> retains only the latest unclaimed value per key while fencing every claimed sink attempt by connection generation and epoch. UpsertDesired, Claim, AckApplied, FailRetryable, and Reconnect implement the lazily-spec state machine: failures requeue work, newer pending values supersede older ones, the durable frontier never moves backward, and stale receipts cannot clear newer desire.

LatestDurableProjection, ThreadSafeLatestDurableProjection, and AsyncLatestDurableProjection add reactive entry and generation views for the three native context families. Sink side effects stay outside the graph; run the state-machine mutations and the actual write on the same per-key serialization lane.

The model

Two cell kinds, one read surface, one write surface.

  • Source<T> is a value written from outside the graph. It is the only kind carrying Set/Merge, so write protection lives in the type rather than in a runtime gate.
  • Computed<T> derives a value from upstream. It is lazy and cached, and guarded by default: a recompute yielding an equal value suppresses the whole downstream cascade.
  • Effect is the only sink. There are no observers, no subscriptions, and no change callbacks on a handle — if you want to react to something, you write an effect.

Tracking is value-threaded, never ambient. A compute body receives a Compute view carrying the recomputing node's identity as a value:

var total = ctx.Computed<int>(c => a.Get(c) + b.Get(c));   // tracked: forms edges
var once  = ctx.Computed<int>(c => a.Get(c.Untracked()));  // untracked: forms none

There is no ambient recompute stack to read from, so Untracked() is genuinely untracked and a read outside a compute cannot accidentally attribute an edge to whatever ran last. C# cannot bind the view to its recompute the way a lifetime does, so escape is caught at runtime: using a stored view after its compute returned throws StaleComputeException instead of silently registering an edge against a node that is no longer recomputing.

Invalidation is a non-consuming mark-frontier walk. A write marks its transitive dependent cone stale and leaves every edge in place; nothing recomputes until something is read. Because the edges survive, a node can be marked clean again without recomputing and still be reachable from its source — which is what keeps the next genuine change from being lost at depth two.

Batching coalesces the cascade, never the algebra.

ctx.Batch(() =>
{
    acc.Merge(1);
    acc.Merge(2);
    acc.Merge(3);
});
// three folds happened synchronously; the watcher ran once

Eager is a state, not a kind. computed.Eager() attaches a puller effect that materializes the value immediately and again after every invalidation. Because the puller is an ordinary effect, N invalidations inside a batch coalesce into one pull at the flush. Lazy() reverses it.

Disposal is explicit, and it dirties what survives. Dropping the last C# reference to a node reclaims nothing reactive: the graph holds strong edges, so a long-lived source retains every node that ever read it. Dispose() detaches both edge directions and marks the surviving cone stale. A TeardownScope groups nodes and tears them down in reverse creation order:

var scope = ctx.Scope();
var view = scope.Own(ctx.Computed<int>(c => source.Get(c) * 2));
scope.Close();   // reverse creation order; effect cleanups run

A divergent feedback loop reports exhaustion instead of hanging. An effect that writes into its own dependency cone closes a loop through the scheduler, not the graph — it is not a dependency cycle, and it runs flat at constant stack depth, so neither acyclicity nor recursion bounds can catch it. Context.DrainBudget is the only exit, and LastDrainExhaustion identifies the effect that concentrated the runs rather than merely announcing that a counter was hit.

Concurrency layers

.NET has real threads, so both concurrency layers are required of this binding rather than declared none.

ThreadSafeContext — lock-backed. It wraps a single-threaded Context with a reentrant lock and reuses the core batch coalescing, so it refines the kernel rather than reimplementing it: a one-write critical section is observationally a plain Set, and a batch of concurrent writes coalesces into one invalidation pass whose result is a function of the serialized write list, not of the interleaving the lock happened to pick.

var ts = new ThreadSafeContext();
Source<int> total = null!;
ts.WithLock(ctx => total = ctx.Source(0));

ts.Batch(() =>          // three writes, one coalesced cascade
{
    ts.Set(total, 1);
    ts.Set(total, 2);
    ts.Set(total, 3);
});

var now = ts.WithLock(_ => total.Peek());

ThreadSafeKernel.ApplyBatch / FlushBatch are the pure counterpart of the Lean LazilyFormal.ThreadSafe model — the coalescing law over a plain node table, checkable without a live graph.

AsyncContext — a distinct graph. It is not an overload of the other two. An async slot can be in flight when its inputs change, can complete after those inputs are gone, and can be cancelled mid-flight, so it carries an explicit state machine (Empty / Computing / Resolved / Error), a revision per slot, and its own handles. Sources stay the synchronous input layer: Source, Peek, and Set are synchronous; only computed evaluation and effects are async.

await using var ctx = new AsyncContext();
var userId = ctx.Source(1);
var profile = ctx.Computed(async cc => await FetchAsync(cc.Track(userId), cc.Token));

Console.WriteLine(await profile.GetAsync());
userId.Set(2);                       // supersedes the in-flight compute
Console.WriteLine(await profile.GetAsync());

The contract it honours in full:

  • Revision tracking discards every stale completion. A run publishes only while the slot still holds the token it started with, so a value the graph has already moved past is never served.
  • Dropping one waiter cancels only that waiter. The shared computation keeps running for the readers that remain, and there is at most one in flight per revision — concurrent readers attach rather than spawning duplicates.
  • GetAsync re-resolves rather than asserting. The slot can change between lock acquisitions and a superseded run closes its waiters without a value; both windows are benign and neither throws.
  • Effect cleanup runs on rerun or dispose and at no other time — never at the end of the flush that ran the body. The canonical effect acquires in the body and releases in the cleanup, so a flush-end cleanup would release while the effect is still live. Reruns are serialized: the next body does not start until the previous cleanup completes.
  • Disposal awaits. Disposing the context cancels every in-flight computation and awaits every active cleanup before returning.
  • Batch is synchronous at the mutation boundary. Writes queue their roots; async reruns fire after the outermost batch exits, never inside it.

Merge algebra

A Source<T> folds writes under a MergePolicy<T>; the default is keep-latest, so a plain source is a plain cell (Cell ≡ Source<KeepLatest>). Associativity is a law, verified by the law tests. The flags are declarations about which overflow behaviour is sound downstream: commutativity is the reordering tax, idempotency the durability tax, and only raw FIFO cannot conflate.

Policy Fold Commutative Idempotent Conflates
KeepLatest op — ✅ ✅
Sum a + b ✅ — ✅
Max max(a, b) ✅ ✅ ✅
SetUnion a ∪ b ✅ ✅ ✅
RawFifo a ++ b — — —

The write guard runs on the merged result, so an idempotent policy's no-op merge fires no cascade.

Distributed and native planes

StateProjection atomically folds canonical SnapshotMessage and DeltaMessage frames into a receiver-side graph mirror; gaps and invalid batches fail closed without partial state. Its producer-side counterpart, StateProjectionMirror, emits sorted, coalesced deltas. Queue deltas remain a separate collection projection and are rejected by the graph mirror instead of being silently accepted.

The command-plane-v1 surface is the typed CommandMessage family plus CommandWire, CommandProjection, and CommandRpcClient. Terminal causal receipts are authoritative, stale generations are ignored, conflicting terminals fail closed, and NegotiatedSession prevents RPC use unless both SessionHandshake peers advertised the feature. PeerPermissions independently gates remote reads, writes, effects, subscriptions, snapshots, deltas, and CRDT operations with a default-deny policy.

For native consumers, include/lazily_ffi.h exposes the normative C ABI over NativeAOT. The smoke gate publishes the shared library, verifies all exported symbols, links a C11 consumer, and runs it against the real IPC codec:

./scripts/check-ffi.sh

LazilyMetrics.Snapshot() reports production-path counters without coupling callers to a metrics backend, while LazilyBenchmark.RunSuite() provides deterministic in-process benchmark probes.

Divergences from the reference bindings

  • No comparable bound. Rust and Go bound a source's value so the write guard can use ==. C# has no such bound, so the guard uses EqualityComparer<T>.Default and every constructor accepts an explicit IEqualityComparer<T>. That is strictly more general, and it means a reference type without a value-equality override is guarded by reference identity unless you pass a comparer.
  • Constructors are extension methods. ctx.Source(…), ctx.Computed(…), ctx.Slot(…), and ctx.Effect(…) live on Reactive so the factory names can match the family vocabulary without colliding with the type names they return.
  • AsyncContext serializes on a lock, not an owner loop. lazily-go funnels every graph mutation through one goroutine and a command channel; Monitor is reentrant, so the same invariant is expressed directly as a lock. Bodies run off it on the thread pool.
  • An async slot in Error retries on the next read. lazily-go serves the stored error forever; docs/async.md lists Error → Computing on retry, and the spec is the authority.

Conformance

The cross-language corpus lives in lazily-spec and is never vendored here — a bundled copy drifts from the spec. Clone it beside this repo:

git clone https://github.com/lazily-hub/lazily-spec.git ../lazily-spec
make check

The runner fails hard when the corpus is absent rather than skipping, asserts a positive fixture and assertion count, and keeps an explicit ledger of unsupported fixtures and known divergences — both are asserted to match exactly, so a new divergence fails the build and a fixed one fails it until its entry is deleted. Today lazily-cs opens and replays all 124 canonical fixture files with an empty fixture ledger. Support remains feature-specific: for example, the synchronous queue, topic, and work-queue peers participate, while their not-yet-implemented thread-safe and async flavors remain staged in the matrix.

The runner is parameterised over the execution model and replays the same op stream against Context, ThreadSafeContext, and AsyncContext. That is not thoroughness for its own sake: a cascade that stops one level below the write is correct synchronously and broken asynchronously, because an async read short-circuits on a resolved cache and serves the stale value forever. A single-context replay cannot see it. Constructs one plane does not ship (the eager signal, the merge_cell fold, the bounded drain — all synchronous-kernel constructs) are gated per model with a stated reason, never degraded to the nearest available substitute.

Feature coverage

Generated from coverage.json in lazily-spec — do not edit by hand.

Summary — family × language
Family Rust Python Kotlin JS Dart Zig Go C++ C# GDScript
Reactive graph ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ~
Materialization ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Family sync ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Statecharts ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Keyed collections ✅ ✅ ✅ ✅ ✅ ~ ✅ ✅ ✅ —
Reactive queue ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Broadcast topic ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Work queue ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
CRDT data types ✅ ~ ~ ~ ~ ~ ~ ~ ✅ —
Lossless tree ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Egress ✅ ~ ~ ~ ~ ~ ~ ~ ~ ~
Ingress ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Wire codec ✅ ✅ ✅ ✅ ~ ✅ ✅ ✅ ✅ —
Transport & FFI ✅ ✅ ✅ ~ ~ ✅ ✅ ~ ✅ —
Message passing ✅ ✅ ✅ ✅ ✅ ~ ✅ ✅ ✅ —
Reliable sync ~ ~ ~ ~ ~ ~ ~ ~ ~ —
Durable owner ✅ — — — — — — — — —
Durable capability tiers ~ ~ ~ ~ ~ ~ ~ ~ ~ ~
Distributed plane ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Causal receipts ~ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Security boundary ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Membership ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Coordination ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Presence ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Temporal ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Rate shaping ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Windowing ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Resilience ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Portable stdlib ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Service plane ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —
Instrumentation ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ ✅ —

Roll-up rule: a family cell is ✅ only when every required row in that family is ✅; ~ when the family is mixed (some shipped or partial); — when no required row is shipped or partial; ⊘ only when every required row in the family is not applicable. Rows the spec marks MAY (optional) are excluded from the roll-up — declining an optional feature is not a gap.

A family cell summarises 82 feature rows. For row-level marks, per-cell notes, and platform carve-outs see the canonical coverage matrix in lazily-spec.

Development

make check          # build + test — run before committing
make conformance    # replay the shared lazily-spec fixtures only
make format-check   # dotnet format --verify-no-changes

The lazily family

lazily is one reactive kernel — Source / Computed / Effect, keyed collections, state charts, CRDTs, and a distributed plane — implemented natively in each language and held to a single cross-language contract:

  • lazily-spec — the wire protocol, the generated feature matrix, and the conformance corpus every binding replays.
  • lazily-formal — the Lean 4 formal model the bindings share.
repo language
lazily-rs Rust — the reference implementation
lazily-py Python
lazily-go Go
lazily-kt Kotlin / JVM
lazily-js JavaScript / TypeScript
lazily-cs C# / .NET — you are here
lazily-cpp C++
lazily-zig Zig
lazily-dart Dart / Flutter
lazily-react React / Preact bindings layered over lazily-js — not a separate language binding

The per-binding parity matrix above is generated from coverage.json in lazily-spec, which stays the single source for cross-binding feature coverage.

License

Apache-2.0 — see LICENSE and NOTICE.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  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. 
.NET Core netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.1 is compatible. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.1

  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Lazily:

Package Downloads
Lazily.R3

Optional R3 adapters for Lazily state graphs.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.6.0 76 9/29/2026
0.5.0 110 9/3/2026
0.4.0 113 7/29/2026
0.3.0 108 7/28/2026