Concepts

Compose multi-stage temporal evidence.

Once windows are aligned, comparators can reason about nested containment, coverage with exclusions, transition drift, replay-safe audits, and live provisional rows without hand-written interval joins.

Scenario

A realistic audit has five things happening at once.

Real comparisons rarely produce a single clean overlap. A primary and a backup see different parts of the same outage. A maintenance window partly excludes the outage from the SLA. The incident escalates mid-range, so measurements before and after the boundary must be reported separately. The backup briefly drops out. And the dashboard still has to serve a live number while the window is still open.

Pipeline

Stage the analysis so interpretation stays visible.

Hand-written interval joins tend to blend filtering, normalization, alignment, scoring, and rollup into one query — which is why the answers are hard to audit. Spanfold stages them, so each step is explicit and re-runnable.

1. Scope

Pick window, axis, segments, tags.

Choose what counts as in-scope before asking any analytic question. Narrow by window name, temporal axis, segment values, and tag filters.

2. Normalize

Known-at, horizon, exclusions.

Apply known-at filtering for replay safety, clip open windows to a live horizon, exclude ranges that should not count, and require closed windows for historical runs.

3. Align

Join lanes by key and partition.

Match target and against windows on key, source, partition, and temporal axis. Cohorts collapse many members into one derived lane at this stage.

4. Score

Emit comparator rows.

Overlap, residual, missing, coverage, gap, containment, lead/lag, and as-of rows come out of this stage. Each row preserves its originating window ids and range.

5. Project

Aggregate per segment or tag.

Group rows by segment, tag, source, or bucket. Compute ratios, histograms, and rollups from the evidence rows — not from counters.

6. Publish

Export with finality preserved.

Emit JSON, Markdown, debug HTML, or agent context. Final and provisional rows are kept distinct so a live dashboard cannot pretend to be history.

Row families

Choose the row family that matches the decision.

Coverage

Covered magnitude over eligible target magnitude, with exclusions applied at the normalize stage. Ratios stay drill-downable because every ratio preserves its contributing rows.

Gaps

Uncovered spans surfaced as their own rows. Useful with a minimum-magnitude threshold so that micro-flaps below (say) 30 seconds do not trigger alerts but still appear in exports.

Containment

One window fully encloses another. Use it for maintenance-inside-outage exclusions, parent-contains-child explanation, or release-window envelopes. Partial containment surfaces as its own row kind.

Lead & lag

Signed transition deltas with a tolerance band. Group the rows into buckets to build histograms, or filter by direction (target-leads / target-lags / within-tolerance) for SLA assertions.

As-of

Point-in-time match against the previous or next qualifying window. Combined with known-at filtering, as-of rows let audits replay the evidence that was visible at a specific decision moment.

Live finality

Rows derived from clipped open windows are labelled provisional and carry the horizon metadata. Final rows from closed history stay stable when the same comparison is replayed later.

Episode analytics · .NET preview and Rust

Move from exact spans to occurrence structure.

Episode analysis layers on recorded windows when the analytical unit is an outage, downtime event, detection, or session-like occurrence. The API is available in Spanfold.Episodes for .NET and through WindowHistory::form_episodes and WindowHistory::compare_episodes in Rust.

1. Form within each side

StitchGapsUpTo joins nearby windows into an episode while retaining every source fragment. It changes episode counts, not active coverage.

2. Relate across sides

RelateWithin connects already-formed target and against episodes. Complete connected components classify as one-to-one, split, merge, complex, or unmatched; evidence is not reduced to nearest pairs.

3. Measure fragments and envelopes

Fragments are authoritative for active magnitude. The envelope measures elapsed extent from the earliest fragment start to the latest fragment end, so inactive gaps are never counted as active evidence.

4. Read neutral summaries

Episode-set summaries describe counts, fragmentation, active magnitude, and elapsed extent. Comparison summaries use target/against terminology without assuming either side is correct.

5. Opt into reference metrics

When the target is intentionally authoritative, AsReference() adds recall and precision. That interpretation is explicit rather than silently labelling neutral unmatched episodes as errors.

6. Settle live results at a horizon

RunLive(horizon) preserves provisional finality for horizon-dependent episodes and relations. It makes no watermark or late-record completeness promise.

using Spanfold.Episodes;

var episodes = pipeline.History
    .CompareEpisodes("Provider QA")
    .Target("reference", s => s.Source("provider-a"))
    .Against("detector", s => s.Source("provider-b"))
    .Within(scope => scope.Window("DeviceOffline", TemporalAxis.Timestamp))
    .Normalize(n => n.OnEventTime())
    .StitchGapsUpTo(TimeSpan.FromMinutes(2))
    .RelateWithin(TimeSpan.FromSeconds(30))
    .Run();
use spanfold::{
    ComparisonNormalizationPolicy, ComparisonScope, ComparisonSelector,
    TemporalTolerance,
};

let episodes = pipeline
    .history()
    .compare_episodes("Provider QA")
    .target("reference", ComparisonSelector::for_source("provider-a"))
    .against("detector", ComparisonSelector::for_source("provider-b"))
    .scope(ComparisonScope::window("DeviceOffline").on_event_time())
    .normalization(ComparisonNormalizationPolicy::event_time())
    .stitch_gaps_up_to(TemporalTolerance::timestamp_ticks(
        2 * 60 * 10_000_000,
    )?)
    .relate_within(TemporalTolerance::timestamp_ticks(
        30 * 10_000_000,
    )?)
    .run()?;

For a reference outage [10:00, 10:30) and detector windows [10:02, 10:10) and [10:12, 10:28), a two-minute stitch forms one multi-fragment detector episode. Exact comparison still exposes the coverage differences; the episode view says the detector saw the same occurrence. A stitch tolerance below the two-minute gap forms two detector episodes and produces a split relation.

Hard examples

Four real audits that combine several concepts.

Each example combines explicit eligibility, selectors and scope, normalization, comparator rows, or live finality. Read them as templates — the shape is the contribution, not the provider names.

1. Coverage SLA with maintenance exclusions

Payments outages must be covered by backup at 99.5%, but scheduled maintenance is excluded — unless the incident has already escalated, in which case the exclusion no longer applies. Model that eligibility upstream as a derived window containing ordinary outages plus escalated maintenance.

// Derived upstream from ordinary outages plus escalated maintenance.
var sla = pipeline.History
    .Compare("Payment coverage SLA")
    .Target("primary", s => s.Source("primary"))
    .Against("backup", s => s.Source("backup"))
    .Within(scope => scope.Window(
        "PaymentOutageSlaEligible",
        TemporalAxis.Timestamp))
    .Normalize(n => n.OnEventTime().RequireClosedWindows())
    .Using(c => c.Coverage().Residual().Containment())
    .Run();

var targetMagnitude = sla.CoverageSummaries
    .Sum(summary => summary.TargetMagnitudeExact);
var coveredMagnitude = sla.CoverageSummaries
    .Sum(summary => summary.CoveredMagnitudeExact);
var ratio = targetMagnitude == 0
    ? 1d
    : (double)coveredMagnitude / targetMagnitude;

var breaches = sla.ResidualRows
    .Where(row => row.Range.GetTimeDuration() > TimeSpan.FromMinutes(5))
    .OrderByDescending(row => row.Range.GetTimeDuration())
    .ToArray();
use spanfold::{ComparisonNormalizationPolicy, ComparisonScope};

// Derived upstream from ordinary outages plus escalated maintenance.
let sla = pipeline
    .history()
    .compare("Payment coverage SLA")
    .target_source("primary")
    .against_source("backup")
    .scope(
        ComparisonScope::window("PaymentOutageSlaEligible")
            .on_event_time(),
    )
    .normalization(ComparisonNormalizationPolicy::event_time())
    .coverage()
    .residual()
    .containment()
    .run();

let target_magnitude: i128 = sla.coverage_summaries
    .iter()
    .map(|summary| summary.target_magnitude_exact)
    .sum();
let covered_magnitude: i128 = sla.coverage_summaries
    .iter()
    .map(|summary| summary.covered_magnitude_exact)
    .sum();
let ratio = if target_magnitude == 0 {
    1.0
} else {
    covered_magnitude as f64 / target_magnitude as f64
};

let mut breaches: Vec<_> = sla
    .residual_rows
    .iter()
    .filter(|row| row.range.end - row.range.start > 5 * 60 * 10_000_000)
    .collect();
breaches.sort_by_key(|row| std::cmp::Reverse(row.range.end - row.range.start));

2. Transition drift histogram across 30 days

Two providers should open their outage windows within 500 ms of each other. Bucket the signed deltas into 100 ms buckets and flag buckets whose out-of-tolerance rows exceed 5% of the population.

var periodEnd = DateTimeOffset.UtcNow;
var periodStart = periodEnd.AddDays(-30);

var drift = pipeline.History
    .Compare("Provider start-drift")
    .Target("primary", s => s.Source("primary")
        .And(s.TimeRange(periodStart, periodEnd)))
    .Against("secondary", s => s.Source("secondary")
        .And(s.TimeRange(periodStart, periodEnd)))
    .Within(scope => scope.Window("DeviceOffline", TemporalAxis.Timestamp))
    .Normalize(n => n.OnEventTime())
    .Using(c => c.LeadLag(
        LeadLagTransition.Start,
        TemporalAxis.Timestamp,
        toleranceMagnitude: TimeSpan.FromMilliseconds(500).Ticks))
    .Run();

var buckets = drift.LeadLagRows
    .Where(row => row.DeltaMagnitude.HasValue)
    .GroupBy(row => (long)Math.Round(
        TimeSpan.FromTicks(row.DeltaMagnitude!.Value).TotalMilliseconds / 100d) * 100)
    .Select(g => new
    {
        BucketMs       = g.Key,
        Count          = g.Count(),
        OutOfTolerance = g.Count(r => !r.IsWithinTolerance)
    })
    .OrderBy(b => b.BucketMs)
    .ToArray();

var total = drift.LeadLagRows.Count;
var hot = buckets.Where(b => b.OutOfTolerance > total * 0.05).ToArray();
var absoluteDriftTicks = drift.LeadLagRows
    .Where(r => r.DeltaMagnitude.HasValue)
    .Select(r => Math.Abs(r.DeltaMagnitude!.Value))
    .OrderBy(magnitude => magnitude)
    .ToArray();
var p95AbsoluteDriftTicks = absoluteDriftTicks.Length == 0
    ? (long?)null
    : absoluteDriftTicks[(int)((absoluteDriftTicks.Length - 1) * 0.95)];
use std::collections::BTreeMap;
use spanfold::{
    Comparator, ComparisonNormalizationPolicy, ComparisonScope,
    ComparisonSelector, LeadLagTransition, TemporalAxis,
};

let period = ComparisonSelector::for_time_range(
    period_start_ticks,
    Some(period_end_ticks),
)?;
let drift = pipeline
    .history()
    .compare("Provider start-drift")
    .target_selector(ComparisonSelector::for_source("primary").and(period.clone()))
    .against_selector(ComparisonSelector::for_source("secondary").and(period))
    .scope(ComparisonScope::window("DeviceOffline").on_event_time())
    .normalization(ComparisonNormalizationPolicy::event_time())
    .use_comparator(Comparator::LeadLag {
        transition: LeadLagTransition::Start,
        axis: TemporalAxis::Timestamp,
        tolerance_magnitude: 500 * 10_000,
    })
    .run();

let mut buckets = BTreeMap::new();
for row in drift.lead_lag_rows.iter().filter(|row| row.delta_magnitude.is_some()) {
    let delta_ms = row.delta_magnitude.expect("filtered") / 10_000;
    let bucket_ms = ((delta_ms as f64 / 100.0).round() as i64) * 100;
    let counts = buckets.entry(bucket_ms).or_insert((0_usize, 0_usize));
    counts.0 += 1;
    counts.1 += usize::from(!row.is_within_tolerance);
}

let total = drift.lead_lag_rows.len();
let hot: Vec<_> = buckets
    .iter()
    .filter(|(_, (_, outside))| *outside as f64 > total as f64 * 0.05)
    .collect();
let mut absolute_deltas: Vec<_> = drift.lead_lag_rows
    .iter()
    .filter_map(|row| row.delta_magnitude.map(i64::unsigned_abs))
    .collect();
absolute_deltas.sort_unstable();
let p95_absolute_drift_ticks = absolute_deltas
    .get(absolute_deltas.len().saturating_sub(1) * 95 / 100)
    .copied();

3. Replay-safe retro audit at two snapshots

Did the risk picture change between two decision points because of late-arriving events? Regenerate the view at both known-at horizons and diff window identities and ranges.

var earlierPlan = pipeline.History
    .Compare("Risk view @ 12,000")
    .Target("risk",   s => s.Source("risk-service"))
    .Against("market", s => s.Source("market-feed"))
    .Within(scope => scope.Window("HighRisk"))
    .Normalize(n => n.KnownAtPosition(12_000))
    .Using(c => c.Overlap().Residual().Missing());

var laterPlan = pipeline.History
    .Compare("Risk view @ 12,847")
    .Target("risk",   s => s.Source("risk-service"))
    .Against("market", s => s.Source("market-feed"))
    .Within(scope => scope.Window("HighRisk"))
    .Normalize(n => n.KnownAtPosition(12_847))
    .Using(c => c.Overlap().Residual().Missing());

var earlier = earlierPlan.Prepare().NormalizedWindows;
var later = laterPlan.Prepare().NormalizedWindows;
var earlierById = earlier.ToDictionary(window => window.RecordId);

var retroactive = later
    .Where(window => !earlierById.ContainsKey(window.RecordId))
    .ToArray();

var restated = later
    .Where(window => earlierById.TryGetValue(window.RecordId, out var before)
        && before.Range != window.Range)
    .ToArray();
use std::collections::BTreeMap;

let earlier = pipeline
    .history()
    .compare("Risk view @ 12,000")
    .target_source("risk-service")
    .against_source("market-feed")
    .scope_window("HighRisk")
    .known_at_position(12_000)
    .overlap()
    .residual()
    .missing()
    .prepare();

let later = pipeline
    .history()
    .compare("Risk view @ 12,847")
    .target_source("risk-service")
    .against_source("market-feed")
    .scope_window("HighRisk")
    .known_at_position(12_847)
    .overlap()
    .residual()
    .missing()
    .prepare();

let earlier_by_id: BTreeMap<_, _> = earlier
    .normalized_windows()
    .iter()
    .map(|window| (window.record_id.as_str(), &window.range))
    .collect();
let retroactive: Vec<_> = later
    .normalized_windows()
    .iter()
    .filter(|window| !earlier_by_id.contains_key(window.record_id.as_str()))
    .collect();
let restated: Vec<_> = later
    .normalized_windows()
    .iter()
    .filter(|window| earlier_by_id
        .get(window.record_id.as_str())
        .is_some_and(|before| *before != &window.range))
    .collect();

4. Live dashboard with provisional banding

The live coverage number is what the dashboard shows; the final number is what survives replay. Keep both, and compute how much of the live number is still provisional.

var horizon = TemporalPoint.ForPosition(currentPosition);

var live = pipeline.History
    .Compare("Live coverage")
    .Target("primary", s => s.Source("primary"))
    .Against("backup",  s => s.Source("backup"))
    .Within(scope => scope.Window("PaymentOutage"))
    .Using(c => c.Coverage())
    .RunLive(horizon);

static double Ratio(IEnumerable<CoverageRow> rows)
{
    var covered = rows.Sum(r => r.CoveredMagnitude);
    var target  = rows.Sum(r => r.TargetMagnitude);
    return target == 0 ? 1d : covered / target;
}

var coverage = live.CoverageRowsWithFinality().ToArray();
var final = coverage
    .Where(entry => entry.Metadata.Finality == ComparisonFinality.Final)
    .Select(entry => entry.Row);
var provisional = coverage
    .Where(entry => entry.Metadata.Finality == ComparisonFinality.Provisional)
    .Select(entry => entry.Row);

var finalRatio    = Ratio(final);
var liveRatio     = Ratio(final.Concat(provisional));
var provisionalPp = liveRatio - finalRatio;

dashboard.Render(
    finalRatio: finalRatio,
    liveRatio:  liveRatio,
    provisionalBanding: provisionalPp,
    horizon:    horizon);
use spanfold::{ComparisonFinality, CoverageRow, TemporalPoint};

let horizon = TemporalPoint::position(current_position);
let live = pipeline
    .history()
    .compare("Live coverage")
    .target_source("primary")
    .against_source("backup")
    .scope_window("PaymentOutage")
    .coverage()
    .run_live(horizon.clone());

fn ratio<'a>(rows: impl IntoIterator<Item = &'a CoverageRow>) -> f64 {
    let (covered, target) = rows.into_iter().fold((0_i128, 0_i128), |totals, row| {
        (
            totals.0 + i128::from(row.covered_magnitude),
            totals.1 + i128::from(row.target_magnitude),
        )
    });
    if target == 0 { 1.0 } else { covered as f64 / target as f64 }
}

let coverage: Vec<_> = live.coverage_rows_with_finality()?.collect();
let final_rows: Vec<_> = coverage
    .iter()
    .filter(|entry| entry.metadata.finality == ComparisonFinality::Final)
    .map(|entry| entry.row)
    .collect();
let provisional_rows: Vec<_> = coverage
    .iter()
    .filter(|entry| entry.metadata.finality == ComparisonFinality::Provisional)
    .map(|entry| entry.row)
    .collect();

let final_ratio = ratio(final_rows.iter().copied());
let live_ratio = ratio(
    final_rows.iter().chain(&provisional_rows).copied(),
);
let provisional_pp = live_ratio - final_ratio;

dashboard.render(
    final_ratio,
    live_ratio,
    provisional_pp,
    horizon,
);

Reading order

Look at rows first, then ratios.

A comparison result is most useful when ratios come out of the evidence rows instead of replacing them. That is what keeps audits explainable: every summary number can be walked back to the ranges, window ids, segments, and sources that produced it.

The same evidence rows feed live dashboards, incident reports, regulator-facing audits, and retrospective root-cause reviews — with finality preserved, so a provisional live number can never be mistaken for a settled historical one.