C# ↔ Rust portability

Carry the temporal question across runtimes.

Spanfold's C# and Rust APIs share the same model—record windows, select two sides, normalize, align, compare, then inspect or export evidence. This guide maps that workflow by task while preserving the idioms and package boundaries of each runtime.

Choose a runtime

Package status is not symmetrical today.

Repository source is ahead of published packages in places. The current .NET source is ahead of NuGet.org, and ordered sequences landed after the crates.io 0.1.1 release, so check feature-level availability before porting a source example into an installed package.

ConcernC# / .NETRust
Core librarySpanfold 0.1.0-preview.2 on NuGet.org; repository source is 0.2.0-preview.1.spanfold 0.1.1 on crates.io.
Testing helpersSpanfold.Testing 0.1.0-preview.2 is a separate NuGet package.SpanfoldAssert, SpanfoldSnapshot, fixture builders, and the virtual clock ship in spanfold.
Exports and bundlesSpanfold.Artifacts joins Spanfold and Spanfold.Testing as a supported package in the repository's 0.2.0-preview.1 release set.Deterministic export functions ship in spanfold; audit-bundle assembly is owned by spanfold-cli.
Command lineThe supported Spanfold.Cli surface is checkout-only and runs through its source project; it is not published as a NuGet tool.spanfold-cli 0.1.1 is published; its installed command is spanfold.

C# · published packages

dotnet add package Spanfold --version 0.1.0-preview.2
dotnet add package Spanfold.Testing --version 0.1.0-preview.2
dotnet tool install --global Spanfold.Cli --version 0.1.0-preview.2

Rust · published crates

cargo add spanfold@0.1.1
cargo install spanfold-cli --version 0.1.1

Task map

Translate intent, not casing.

The nouns remain stable. C# uses properties, extension methods, and configuring delegates; Rust uses borrowed history, consuming builders, explicit values, and Result at fallible boundaries.

TaskC# / .NET sourceRust 0.1.1
Start a pipelineEventPipeline.For<TEvent>()for_events::<T>()
Record history.RecordWindows().record_windows()
Add one window and finish.TrackWindow(...) returns the pipeline.track_window(...).build()?
Add several windows.Window(...).Window(...).Build().window(...).window(...).build()?
Ingest one / manyIngest(...) / IngestMany(...)ingest(...)? / ingest_many(...)?
Borrow recorded historypipeline.Historypipeline.history()
Adopt materialized historyConstruct ClosedWindow/OpenWindow, then call WindowHistory.FromRecords(...)Deserialize a validated WindowHistory, or use fixture/tool adapters; external crates cannot construct the non-exhaustive record structs directly
Query a laneQuery().Window(...).Lane(...).ClosedWindows()query().where_window(...).where_source(...).closed_windows()
Snapshot at a horizonSnapshotAt(TemporalPoint.ForPosition(...))snapshot_at(TemporalPoint::position(...))?
Start a comparisonhistory.Compare(name)history.compare(name)
Select sidesTarget(name, ...) / Against(name, ...)target_selector(...) / against_selector(...), or source conveniences
Scope and normalizeWithin(...) / Normalize(...)scope(...) / normalization(...)
Choose rowsUsing(c => c.Overlap().Residual()...)overlap().residual()...
Validate / prepare / alignValidate() / Prepare() / prepared.Align()validate() / prepare() / align()
Run final / liveRun() / RunLive(horizon)run() / run_live(horizon)

Record

Build and ingest with the same lane identity.

In both runtimes, source and partition are ingestion context. Processing position advances once per committed event, and recording must be enabled before history-based analysis is useful.

C# · current source API

var pipeline = EventPipeline
    .For<DeviceStatus>()
    .RecordWindows()
    .TrackWindow(
        "DeviceOffline",
        e => e.DeviceId,
        e => !e.IsOnline);

var ingestion = pipeline.Ingest(
    new DeviceStatus("device-17", false),
    source: "provider-a",
    partition: "eu-west");

var history = pipeline.History;

Rust

let mut pipeline = spanfold::for_events::<DeviceStatus>()
    .record_windows()
    .track_window(
        "DeviceOffline",
        |e| e.device_id.clone(),
        |e| !e.is_online,
    )
    .build()?;

let ingestion = pipeline.ingest(
    DeviceStatus { device_id: "device-17".into(), is_online: false },
    Some("provider-a"),
    Some("eu-west"),
)?;

let history = pipeline.history();

The published .NET 0.1.0-preview.2 examples use Spanfold.Spanfold.For<TEvent>(). In current source, EventPipeline.For<TEvent>() is the preferred entry point and the older static entry point is obsolete.

For a batch with one Rust lane, ingest_many(events, source, partition) applies that context to every event. The C# IngestMany(events) overload has no lane parameters; call Ingest(event, source, partition) for each event when source or partition matters.

In current C# source, emission callbacks run after the event and its history have committed. An IngestionCallbackException<TEvent> reports callback failures and carries the committed result; do not blindly retry that event.

Inspect

Query first when one history answers the question.

Both APIs filter by window name, key, source, partition, segment, and tag. A snapshot clips evidence to an explicit horizon and labels records derived from still-open windows as provisional.

C#

var closed = history.Query()
    .Window("DeviceOffline")
    .Key("device-17")
    .Lane("provider-a")
    .ClosedWindows();

var snapshot = history.SnapshotAt(
    TemporalPoint.ForPosition(42));
var visible = snapshot.Query()
    .Window("DeviceOffline")
    .Windows();

Rust

let closed = history.query()
    .where_window("DeviceOffline")
    .where_key("device-17")
    .where_source("provider-a")
    .closed_windows();

let snapshot = history.snapshot_at(
    TemporalPoint::position(42),
)?;
let visible = snapshot.query()
    .where_window("DeviceOffline")
    .windows();

Compare

The comparison stages have direct counterparts.

Build captures the question. Validate critiques the plan. Prepare selects, normalizes, and records exclusions. Align partitions time into comparable segments. Run executes the requested comparators and materializes rows.

C#

var comparison = history
    .Compare("Provider QA")
    .Target("primary", s => s.Source("provider-a"))
    .Against("secondary", s => s.Source("provider-b"))
    .Within(s => s.Window("DeviceOffline"))
    .Using(c => c
        .Overlap()
        .Residual()
        .Missing()
        .Coverage());

var diagnostics = comparison.Validate();
var prepared = comparison.Prepare();
var aligned = prepared.Align();
var result = comparison.Run();

Rust

let comparison = history
    .compare("Provider QA")
    .target_selector(
        ComparisonSelector::for_source("provider-a")
            .with_name("primary"),
    )
    .against_selector(
        ComparisonSelector::for_source("provider-b")
            .with_name("secondary"),
    )
    .scope_window("DeviceOffline")
    .overlap()
    .residual()
    .missing()
    .coverage();

let diagnostics = comparison.validate();
let prepared = comparison.prepare();
let aligned = comparison.align();
let result = comparison.run();

Use serializable selectors when the plan will be exported. Rust also permits ComparisonSelector::runtime_only(...) for local predicates, but deterministic plan and result exports reject that plan as non-portable.

Consume evidence

Row families correspond; finality contracts differ.

Use the typed collections for analysis, then use the finality-aware views when a live horizon can make a row provisional. Finality describes horizon dependence; it is not a watermark or a claim that late records cannot arrive.

EvidenceC#Rust
Core rowsOverlapRows, ResidualRows, MissingRowsoverlap_rows, residual_rows, missing_rows
Advanced rowsCoverageRows, GapRows, SymmetricDifferenceRows, ContainmentRows, LeadLagRows, AsOfRowsThe same names in snake_case.
Finality-aware viewResidualRowsWithFinality()residual_rows_with_finality()?
Finality valuesComparisonFinality.Final and Provisional.The non-exhaustive ComparisonFinality also includes Revised and Retracted. Handle unknown future variants rather than assuming only Final and Provisional.
Stage evidenceresult.Prepared and result.Aligned are typed optional stage objects; diagnostics are typed in result.Diagnostics.result.prepared and result.aligned are Option<serde_json::Value>, not direct typed counterparts; diagnostics remain in result.diagnostics.

C# · live rows

var live = comparison.RunLive(
    TemporalPoint.ForPosition(42));

var provisional = live
    .ResidualRowsWithFinality()
    .Where(row =>
        row.Metadata.Finality ==
        ComparisonFinality.Provisional);

Rust · live rows

let live = comparison.run_live(
    TemporalPoint::position(42),
);

let provisional = live
    .residual_rows_with_finality()?
    .filter(|row|
        row.metadata.finality ==
        ComparisonFinality::Provisional);

Export and audit

The formats correspond; the owning package does not.

Both implementations emit deterministic JSON, JSON Lines, Markdown, debug HTML, and LLM-context output. Keep the comparison runtime free of implicit file writes: create a result first, then export it or explicitly choose a configured export path.

OutputC# · Spanfold.ArtifactsRust · spanfold
Plan JSONplan.ExportJson()export_plan_json(&plan)?
Result JSONresult.ExportJson()export_result_json(&result)?
JSON Linesresult.ExportJsonLines()export_result_json_lines(&result)? or write_result_json_lines(...)?
Markdownresult.ExportMarkdown()export_result_markdown(&result)
Debug HTMLresult.ExportDebugHtml(path)export_result_debug_html(&result), or a configured builder export
LLM contextresult.ExportLlmContext()export_result_llm_context(&result)?, or a configured builder export
Audit bundleAuditBundleWriter.Write(...); AuditBundleReader.Open(...).Verify() checks manifest integrity.spanfold audit ..., audit-windows, or audit-events assemble CLI bundles.

A .NET bundle verifier checks file sizes and SHA-256 digests against its manifest; it does not authenticate the producer. The Rust CLI bundle is a deterministic artifact collection, but it does not expose the .NET integrity-verification contract.

Occurrences and journeys

Episodes and ordered sequences exist in both runtimes.

Episode analysis stitches nearby fragments within each side, then relates formed occurrences across sides. Sequences instead match literal named window families in onset order within one key/source/partition lane.

C# · Episodes

using Spanfold.Episodes;

var episodes = history
    .CompareEpisodes("Provider QA")
    .Target("reference", s => s.Source("provider-a"))
    .Against("detector", s => s.Source("provider-b"))
    .Within(s => s.Window("DeviceOffline"))
    .StitchGapsUpTo(2L)
    .RelateWithin(1L)
    .Run();

var scorecard = episodes.AsReference();

Rust · Episodes

let episodes = history
    .compare_episodes("Provider QA")
    .target(
        "reference",
        ComparisonSelector::for_source("provider-a"),
    )
    .against(
        "detector",
        ComparisonSelector::for_source("provider-b"),
    )
    .scope(ComparisonScope::window("DeviceOffline"))
    .stitch_gaps_up_to(TemporalTolerance::processing_positions(2)?)
    .relate_within(TemporalTolerance::processing_positions(1)?)
    .run()?;

let scorecard = episodes.as_reference();

C# · ordered sequence

using Spanfold.Sequences;

var journeys = history
    .MatchSequence("incident journey")
    .Step("Warning")
    .Then("Offline")
    .Then("Recovered")
    .WithMaximumGap(5)
    .Run()
    .Matches;

Rust · ordered sequence

let journeys = history
    .match_sequence("incident journey")
    .step("Warning")
    .then("Offline")
    .then("Recovered")
    .with_maximum_gap(5)
    .run()?;

Portable Episode document schema version 1 is processing-position only. The .NET and Rust CLIs can execute the same document and emit aligned deterministic JSON or Markdown; runtime-specific Episode IDs are deliberately omitted from that portable result.

Ordered sequences are current-source APIs in both runtimes. They landed after the published NuGet 0.1.0-preview.2 and crates.io 0.1.1 packages, and there is no portable sequence document or CLI route.

Automation

Share fixtures, then use each runtime's native harness.

The versioned contract fixture and Episode document are the portable boundaries. Assertion APIs are intentionally language-native and return or throw in the style of their host runtime.

WorkflowC# / .NET source CLIRust 0.1.1 CLI
Shared fixture pathsvalidate-plan, compare, explain, audit, audit-windows, and episodes
Event importNo CLI event-map import route.import-events and audit-events accept mapped JSON Lines or header-row CSV.
Acceptance policiescheck and suite execute assessment specifications.No assessment-specification CLI route.
Bundle lifecycleverify-bundle and diff.No bundle verification or bundle-diff command.
Fixture builderWindowHistoryFixtureBuilderWindowHistoryFixture
AssertionsSpanfoldAssert throws SpanfoldAssertionException.SpanfoldAssert returns Result<_, SpanfoldAssertionError>.
Snapshots and timeSpanfoldSnapshot, VirtualComparisonClockThe same type names with snake_case methods.

C# · source checkout

dotnet run --project packages/dotnet/src/Spanfold.Cli/Spanfold.Cli.csproj -- \
  compare fixture.json --format json

Rust · installed command

spanfold compare fixture.json --format json

Runtime differences and current gaps

Do not infer parity beyond the documented contract.

Release availability

NuGet.org currently publishes Spanfold and Spanfold.Testing 0.1.0-preview.2. The .NET 0.2.0-preview.1 release set adds Spanfold.Artifacts as a supported package; its CLI remains checkout-only. Rust library and CLI 0.1.1 are published.

Portable identity

Compare semantic rows, ranges, finality, and evidence lineage across runtimes. Current row IDs are runtime-specific, and portable Episode output intentionally excludes runtime Episode IDs.

Portable time

Contract fixtures and portable Episode schema version 1 use processing positions. Portable timestamp-known-at input remains deferred until both runtimes share one explicit clock and availability contract.

Tooling specialties

Rust owns declarative event JSONL/CSV import. .NET owns assessment specifications, integrity-verifiable bundles, and artifact revision/diff workflows. These are supported runtime-specific capabilities, not missing aliases to invent in application code.

Execution boundary

Neither runtime is a hosted stream processor, durable queue, scheduler, or persistence layer. Pipelines are synchronous and callers own IO, retention, ingestion serialization, and when a live comparison is rerun. The live stream operations guide maps that ownership into a safe end-to-end workflow; the existing-history guide covers adoption without event replay.

Conformance status

The main capability map is implemented in both runtimes, but broader cross-language conformance gates and published workload baselines remain incomplete. Treat deterministic shared documents and fixtures—not file layout or method spelling—as the compatibility surface.

Next

Use the language reference for the full member surface.

This guide is the translation layer. The dedicated references remain authoritative for overloads, errors, advanced comparators, cohorts, source matrices, hierarchy, liveness, and detailed result types.