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.
Guide · C# and Rust
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
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.
You need every overlap, residual, missing, coverage, containment, or lead/lag span as the result. Those rows preserve the precise temporal disagreement.
You need occurrence counts, fragmented-versus-continuous behavior, missed occurrences, duplicate detections, or the shape of one-to-many and many-to-one relationships.
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
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
Target and against selectors decide which recorded windows can contribute. The named-window scope and temporal axis must agree with normalization.
2. Normalize
Known-at filtering, closed-window requirements, or live clipping happen before formation, so retained fragments already carry their effective range and finality.
3. Group
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
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
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 kind | Target Episodes | Against Episodes | Meaning |
|---|---|---|---|
OneToOne | 1 | 1 | One occurrence on each side belongs to the component. |
Split | 1 | 2 or more | One target occurrence relates to several against occurrences. |
Merge | 2 or more | 1 | Several target occurrences relate to one against occurrence. |
Complex | 2 or more | 2 or more | A many-to-many component cannot be reduced without losing evidence. |
UnmatchedTarget | 1 | 0 | A target occurrence has no qualifying against edge. |
UnmatchedAgainst | 0 | 1 | An against occurrence has no qualifying target edge. |
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.
Stitch tolerance changes formation within each side. Relation tolerance changes graph edges across sides. Neither changes the source fragments or their active union.
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 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.
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.
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.
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
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.
The materialized evidence has settled relative to the supplied horizon and configured tolerances.
The Episode or relation can still change as an open window extends or as a future fragment arrives within its settling tolerance.
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
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.
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 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.
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
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
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}
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
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 contract | Schema version 1 behavior |
|---|---|
| Plan schema | spanfold.episode.analysis, schemaVersion: 1. |
| Result schema | spanfold.episode.analysis.result, schemaVersion: 1. |
| Axis | processingPosition only; timestamp clocks and portable known-at input are deferred. |
| Identity | String keys and string-or-null partitions. Native Episode IDs are omitted; relations use deterministic per-side indexes. |
| Formats | Deterministic pretty JSON or deterministic Markdown from either CLI. |
| Live behavior | A 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
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.