Guide · C# and Rust

Analyze occurrences without losing the interval evidence.

Episode analysis groups nearby windows into occurrence-shaped evidence, then compares the complete target-against relation graph. Use it beside exact comparison rows when the decision concerns outages, detections, sessions, or other occurrences rather than only exact coverage.

Choose the analytical unit

Exact rows explain coverage. Episodes explain occurrences.

An exact comparison can show that a detector started late, recovered early, or went inactive briefly. Episode analysis answers the next question: did those fragments still describe the same occurrence? The two views are complementary; forming an Episode does not rewrite the underlying windows or erase their exact differences.

Stay with exact rows when

You need every overlap, residual, missing, coverage, containment, or lead/lag span as the result. Those rows preserve the precise temporal disagreement.

Add Episodes when

You need occurrence counts, fragmented-versus-continuous behavior, missed occurrences, duplicate detections, or the shape of one-to-many and many-to-one relationships.

Read both together

An Episode can say both sources observed one outage while exact rows still show its late onset, early recovery, and inactive gap. Occurrence agreement is not exact interval agreement.

Formation

Normalize, group by lane, then stitch nearby fragments.

Each side is formed independently from normalized window evidence. A formation lane is one window family, key, source, partition, temporal axis, and—on event time—clock. Fragments never stitch across one of those boundaries. Within a lane, overlapping fragments or fragments separated by no more than the stitch tolerance become one Episode.

1. Select

Choose the source evidence.

Target and against selectors decide which recorded windows can contribute. The named-window scope and temporal axis must agree with normalization.

2. Normalize

Apply one temporal policy.

Known-at filtering, closed-window requirements, or live clipping happen before formation, so retained fragments already carry their effective range and finality.

3. Group

Keep lane identity intact.

Window name, logical key, source, partition, axis, and clock define the formation boundary. A tolerance never joins distinct devices, partitions, providers, or clocks.

4. Stitch

Bridge only bounded inactivity.

StitchGapsUpTo / stitch_gaps_up_to controls same-side formation. It can change Episode counts and elapsed extent, but not active coverage.

Suppose target has [10:00, 10:30) and against has [10:02, 10:10) plus [10:12, 10:28) in the same lane. A two-minute stitch forms one against Episode with two fragments. Its envelope is [10:02, 10:28), its active magnitude is the union of the two fragments, and its internal-gap magnitude is two minutes. Lower the tolerance below two minutes and it remains two Episodes instead.

Relation graph

Relate actual fragments, then classify complete components.

RelateWithin / relate_within applies across already-formed target and against Episodes. An edge exists only when at least one target fragment and one against fragment overlap or are within the relation tolerance. Spanfold then finds connected components; it does not greedily choose a nearest pair and discard the rest of the graph.

Relation kindTarget EpisodesAgainst EpisodesMeaning
OneToOne11One occurrence on each side belongs to the component.
Split12 or moreOne target occurrence relates to several against occurrences.
Merge2 or more1Several target occurrences relate to one against occurrence.
Complex2 or more2 or moreA many-to-many component cannot be reduced without losing evidence.
UnmatchedTarget10A target occurrence has no qualifying against edge.
UnmatchedAgainst01An against occurrence has no qualifying target edge.

Envelope overlap is not evidence

An Episode envelope includes its internal inactive gaps. If the other side lies only inside such a gap, the envelopes overlap but no fragments do. Spanfold correctly creates no zero-tolerance edge.

Tolerances own different decisions

Stitch tolerance changes formation within each side. Relation tolerance changes graph edges across sides. Neither changes the source fragments or their active union.

Metrics use active unions

Overlap and coverage are calculated from fragment unions. Signed onset and recovery deltas are against minus target. Timing distributions include only one-to-one components, avoiding arbitrary attribution inside split, merge, or complex graphs.

Interpretation

Target and against are neutral until you opt into a reference.

Target and against define direction for counts, coverage ratios, and signed deltas; they do not say which side is correct. The materialized set and comparison summaries therefore use neutral terms such as matched target, unmatched against, split target rate, and Episode count bias.

Neutral summary

Use Summary / summary() to describe both sides and the graph without assigning truth. This is the safer default for peer providers, pipeline stages, or model versions.

Explicit scorecard

Call AsReference() / as_reference() only when target is intentionally authoritative. It renames target Episodes as references and against Episodes as detections, then exposes recall, precision, and F1.

Graph-safe denominators

A reference is detected when it belongs to any matched component; a detection is matched on the same basis. Split, merge, and complex components remain complete rather than being forced into invented pairs.

Live analysis

A horizon makes open evidence measurable, not complete.

RunLive(horizon) / run_live(horizon) clips open windows to an explicit effective end. Episodes remain provisional when they contain provisional fragments or have not passed their stitch settling boundary. Relations remain provisional when a component is provisional or has not passed its relation settling boundary.

Final

The materialized evidence has settled relative to the supplied horizon and configured tolerances.

Provisional

The Episode or relation can still change as an open window extends or as a future fragment arrives within its settling tolerance.

No watermark promise

A live horizon does not assert source completeness, allowed lateness, retention, or watermark semantics. The caller owns those operational guarantees and reruns analysis as history changes.

Evidence and identity

Keep native lineage; compare portable meaning.

Every native Episode retains its ordered normalized fragments. Each fragment points back to its source window and record identifier and carries the effective range and finality used by formation. Internal gaps are derived from the envelope minus the union of those fragments; a gap is measured inactivity, not synthetic positive evidence.

Native deterministic IDs

Episode IDs are deterministic only under each runtime's own identity inputs. Rust includes Episode finality, so an otherwise unchanged live Episode ID can change when the Episode settles from provisional to final; .NET currently omits Episode finality from its ID input. The algorithms and source-record identity contracts remain runtime-specific.

Portable result indexes

Portable output deliberately omits native Episode IDs. It orders Episodes deterministically and relates them through zero-based per-side indexes, so cross-runtime consumers compare semantic fields rather than opaque identifiers.

Trace boundary

The portable result includes Episode counts, ranges, magnitudes, finality, summaries, and relation indexes. It does not export each native fragment or record ID, so retain the direct result when record-level lineage is required for an audit.

Direct API

Use corresponding journeys, expressed idiomatically.

Both examples form and compare the same processing-position Episode graph. C# uses configuring delegates and throws at invalid boundaries; Rust uses consuming builders, typed tolerances, and Result.

Release availability differs: the repository's 0.2.0-preview.1 release set includes the C# Episode API and Spanfold.Artifacts package, while its CLI remains checkout-only. The Rust spanfold library and spanfold-cli 0.1.1 are published.

using Spanfold;
using Spanfold.Episodes;

var result = history
    .CompareEpisodes("Provider outage QA")
    .Target("provider", selector => selector.Source("provider-a"))
    .Against("detector", selector => selector.Source("detector-b"))
    .Within(scope => scope.Window("DeviceOffline"))
    .Normalize(normalization => normalization.OnPosition())
    .StitchGapsUpTo(1L)
    .RelateWithin(0L)
    .Run();

var splits = result.RelationsOfKind(EpisodeRelationKind.Split);
var neutral = result.Summary;

// Only do this when the target really is authoritative.
var scorecard = result.AsReference();
use spanfold::{
    ComparisonNormalizationPolicy, ComparisonScope, ComparisonSelector,
    EpisodeRelationKind, TemporalTolerance,
};

let result = history
    .compare_episodes("Provider outage QA")
    .target(
        "provider",
        ComparisonSelector::for_source("provider-a"),
    )
    .against(
        "detector",
        ComparisonSelector::for_source("detector-b"),
    )
    .scope(ComparisonScope::window("DeviceOffline"))
    .normalization(ComparisonNormalizationPolicy::default_policy())
    .stitch_gaps_up_to(TemporalTolerance::processing_positions(1)?)
    .relate_within(TemporalTolerance::processing_positions(0)?)
    .run()?;

let splits = result.relations_of_kind(EpisodeRelationKind::Split);
let neutral = result.summary();

// Only do this when the target really is authoritative.
let scorecard = result.as_reference();

For event-time analysis, select event-time scope and normalization in both runtimes and use a duration/timestamp-tick tolerance with one compatible clock. Portable schema version 1 does not carry that clock contract, so event-time Episodes currently remain a direct-API journey.

Portable document · schema v1

Run one definition through either runtime.

spanfold.episode.analysis schema version 1 is a narrow, language-neutral plan. It selects two distinct source strings, one named window family, processing-position normalization, same-side and cross-side tolerances, and an optional live horizon. Both runtimes compile it into their existing Episode builders rather than using a second analysis engine.

{
  "schema": "spanfold.episode.analysis",
  "schemaVersion": 1,
  "name": "Provider and detector offline episodes",
  "target": { "name": "provider", "source": "provider-a" },
  "against": { "name": "detector", "source": "detector-b" },
  "windowName": "DeviceOffline",
  "normalizationAxis": "processingPosition",
  "stitchTolerance": 1,
  "relationTolerance": 0,
  "liveHorizon": 14
}

The accompanying flat window JSON Lines use string keys, string-or-null partitions, non-negative processing positions, and a numeric or null endPosition. Version 1 rejects equal target and against sources, negative tolerances or horizons, and timestamp normalization. Portable execution is intentionally narrower than the direct APIs.

{"windowName":"DeviceOffline","key":"device-1","source":"provider-a","partition":"eu","startPosition":1,"endPosition":4}
{"windowName":"DeviceOffline","key":"device-1","source":"provider-a","partition":"eu","startPosition":5,"endPosition":8}
{"windowName":"DeviceOffline","key":"device-1","source":"detector-b","partition":"eu","startPosition":2,"endPosition":7}
{"windowName":"DeviceOffline","key":"device-2","source":"provider-a","partition":null,"startPosition":10,"endPosition":12}
{"windowName":"DeviceOffline","key":"device-2","source":"detector-b","partition":null,"startPosition":11,"endPosition":null}

C# · source CLI

dotnet run \
  --project packages/dotnet/src/Spanfold.Cli/Spanfold.Cli.csproj \
  -- episodes episode-plan.json windows.jsonl --format json

dotnet run \
  --project packages/dotnet/src/Spanfold.Cli/Spanfold.Cli.csproj \
  -- episodes episode-plan.json windows.jsonl --format markdown

Rust · installed or workspace CLI

spanfold episodes \
  episode-plan.json windows.jsonl --format json

cd packages/rust
cargo run -p spanfold-cli -- episodes \
  ../../episode-plan.json ../../windows.jsonl --format markdown
Portable contractSchema version 1 behavior
Plan schemaspanfold.episode.analysis, schemaVersion: 1.
Result schemaspanfold.episode.analysis.result, schemaVersion: 1.
AxisprocessingPosition only; timestamp clocks and portable known-at input are deferred.
IdentityString keys and string-or-null partitions. Native Episode IDs are omitted; relations use deterministic per-side indexes.
FormatsDeterministic pretty JSON or deterministic Markdown from either CLI.
Live behaviorA non-null liveHorizon clips open windows and preserves provisional Episode and relation finality.

Result v1 carries the analysis and window names, axis, tolerances, evaluation horizon, target and against source metadata, each side's summary and ordered Episodes, the neutral comparison summary, and exhaustive relations referencing those Episode indexes. The repository includes the complete shared plan, window JSONL, and expected JSON result. Use the portable result for cross-runtime exchange; keep a native direct result alongside it when you need full fragment objects and source-record lineage.

Next

Choose the neighboring guide for the next question.

Read exact row semantics in the comparison guide, translate the wider API surface in the C# and Rust map, or use live stream operations to define the ordering and completeness guarantees that Episode analysis intentionally does not own.