mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 13:10:13 +00:00
baa51102fd
An app still installing when the session's observation ended has no terminal
event and never will. The session page ticked Date.now() - startedAt for it
("11h 39m" on a session that finished hours ago), and its AppInstallSummaries
row stayed InProgress forever - ranked behind failures in the app's session
list and counted in totalInstalls with no named bucket.
Web: applyObservationEnd marks such rows Incomplete once the session is
terminal (isTerminalStatus), bounded by session.lastEventAt; the panel shows a
slate Incomplete badge and the watched span as a lower bound, the "active"
pill excludes them, and the download panel carries the parallel flag. Covers
every historic session without a backfill.
Backend: CloseOpenAppInstallsForSessionAsync closes InProgress rows without a
CompletedAt as Incomplete - at the terminal seam, again on the ingest batch
that carries agent_shutting_down for a terminal session (rows opened after
the verdict), and from the 30-day sweep. The sticky-status reconcile keeps
Incomplete against a late progress-only batch and lets a real terminal win.
App metrics and app analytics disclose the bucket (incomplete,
totalIncomplete) outside the failure rate; manifest and wire types
regenerated.
MCP: get_session_summary coverage gains an apps block and a gaps line naming
the apps the agent's last app_tracking_summary still listed as installing or
downloading, terminal sessions only.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
492 lines
24 KiB
C#
492 lines
24 KiB
C#
using AutopilotMonitor.Shared.Models;
|
||
|
||
namespace AutopilotMonitor.Functions.Helpers;
|
||
|
||
/// <summary>
|
||
/// Shared statistical helpers for duration/SLA metrics aggregation.
|
||
/// </summary>
|
||
public static class MetricsMath
|
||
{
|
||
/// <summary>
|
||
/// App-install failure rate over finished installs only: Failed / (Failed + Succeeded), one
|
||
/// decimal, 0 when nothing finished. The same outcome-quota convention as the enrollment
|
||
/// success rate — "InProgress" rows (still installing) and "Incomplete" rows (observation
|
||
/// ended before any outcome) never dilute the rate. Shared by the app-metrics payload and the Apps
|
||
/// dashboard aggregations so the definition can't drift between panes.
|
||
/// </summary>
|
||
public static double TerminalFailureRatePct(int failed, int succeeded)
|
||
{
|
||
var finished = failed + succeeded;
|
||
return finished > 0 ? Math.Round((double)failed / finished * 100, 1) : 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// True when the row's terminal state says no real install attempt happened —
|
||
/// Skipped (not applicable, e.g. WinGet "Update for X" with the target app absent)
|
||
/// or Postponed. PR0 decision (2026-07-26): such rows are excluded from duration
|
||
/// statistics AND from the failure/success rate; they are reported as their own
|
||
/// category instead. Rows written before the TerminalState column existed return
|
||
/// false here (empty state) — they keep the legacy rate behavior because they
|
||
/// cannot be reclassified honestly.
|
||
/// </summary>
|
||
public static bool IsSkipTerminalState(AppInstallSummary s)
|
||
=> s.TerminalState == "Skipped" || s.TerminalState == "Postponed";
|
||
|
||
/// <summary>
|
||
/// Upper plausibility bound for an observed install duration: 6 h, the agent's default
|
||
/// max observation lifetime (AgentMaxLifetimeMinutes = 360) — nothing longer can have
|
||
/// been continuously watched. Historically this mostly caught full-span pathologies
|
||
/// (pre-2026-08 rows measured first observation → last terminal across all install
|
||
/// passes; verified in production 2026-07-27: app rows carrying 60 175 s in a session
|
||
/// whose own wall clock was 2 697 s; 490 rows > 6 h overall, worst 117 days). Since
|
||
/// the 2026-08 attempt-duration change DurationSeconds measures the last attempt, so
|
||
/// far fewer rows trip this bound — it remains as the guard against back-stamped
|
||
/// completions, where a single ATTEMPT longer than the agent's own lifetime is still
|
||
/// impossible to have observed.
|
||
/// </summary>
|
||
public const int MaxPlausibleInstallDurationSeconds = 6 * 3600;
|
||
|
||
/// <summary>
|
||
/// True for a succeeded row whose install duration was actually observed. Zero /
|
||
/// absent duration on a non-skip succeeded row means the START was never observed
|
||
/// (agent attach window — audit finding, §0.5 of the insights spec); a duration above
|
||
/// <see cref="MaxPlausibleInstallDurationSeconds"/> means the END was never observed
|
||
/// (completion back-stamped at session end). Either way the duration is UNKNOWN —
|
||
/// averaging zeros understates (measured −18 % in production), averaging back-stamps
|
||
/// inflates. Duration statistics must only read rows where this is true; everything
|
||
/// else is reported as "unmeasured".
|
||
/// </summary>
|
||
public static bool HasMeasuredDuration(AppInstallSummary s)
|
||
=> s.DurationSeconds > 0 && s.DurationSeconds <= MaxPlausibleInstallDurationSeconds;
|
||
|
||
/// <summary>
|
||
/// Builds the complete app-metrics response object from a (pre-time-filtered) set of app
|
||
/// install summaries. Single source of truth for both the tenant (<c>metrics/app</c>) and
|
||
/// global (<c>global/metrics/app</c>) functions, which previously carried a verbatim copy of
|
||
/// this GroupBy aggregation — keeping the Delivery Optimization rollup and the slowest/failing
|
||
/// ranking in one place removes that drift risk.
|
||
///
|
||
/// The Delivery Optimization rollup sums bytes across every row in an app group (not just the
|
||
/// successful ones): DO telemetry is recorded during the download regardless of the install's
|
||
/// final status. Peer bytes and Microsoft Connected Cache (MCC) bytes are reported separately —
|
||
/// MCC is counted apart from peers by DO — and offload% credits both as "not pulled from the CDN".
|
||
/// </summary>
|
||
public static AppMetricsResponse BuildAppMetricsPayload(IEnumerable<AppInstallSummary> summaries)
|
||
{
|
||
var summaryList = summaries as IList<AppInstallSummary> ?? summaries.ToList();
|
||
|
||
// F1 PR1 (audit Q3): a name-keyed row that merged two distinct appIds carries an
|
||
// unattributable status/duration mix — it must not shape any per-app group. Excluded
|
||
// here with a disclosed count (truthfulness rule 7); the fleet-wide DO rollup below
|
||
// keeps every row, since transferred bytes are real regardless of identity mixing.
|
||
var totalCollisionExcluded = summaryList.Count(s => s.AppIdCollision);
|
||
|
||
var appGroups = summaryList.Where(s => !s.AppIdCollision).GroupBy(s => s.AppName).Select(g =>
|
||
{
|
||
// PR0 (2026-07-26) classification — see IsSkipTerminalState / HasMeasuredDuration:
|
||
// skipped = no real install attempt (TerminalState Skipped/Postponed)
|
||
// installed = succeeded rows that are not known skips (includes legacy rows
|
||
// without TerminalState — they cannot be reclassified)
|
||
// measured = installed rows with an observed duration — the ONLY duration input
|
||
// unmeasured = installed rows whose start was never observed (duration unknown)
|
||
var succeededAll = g.Where(s => s.Status == "Succeeded").ToList();
|
||
var skipped = succeededAll.Where(IsSkipTerminalState).ToList();
|
||
var installed = succeededAll.Where(s => !IsSkipTerminalState(s)).ToList();
|
||
var measured = installed.Where(HasMeasuredDuration).ToList();
|
||
var failed = g.Where(s => s.Status == "Failed").ToList();
|
||
// Observation ended before any outcome — disclosed, outside the rate and the durations.
|
||
var incomplete = g.Count(s => s.Status == "Incomplete");
|
||
var total = g.Count();
|
||
|
||
// DoAggregator is the single source for the DO rollup: it filters rows that actually
|
||
// carry DO telemetry (DoDownloadMode >= 0) and falls back to peers + http when a legacy
|
||
// row reports source bytes but no DoTotalBytesDownloaded — so that telemetry is not lost.
|
||
var doG = DoAggregator.Compute(g);
|
||
|
||
return new AppMetricsAppGroup
|
||
{
|
||
AppName = g.Key,
|
||
TotalInstalls = total,
|
||
Succeeded = installed.Count,
|
||
Skipped = skipped.Count,
|
||
Unmeasured = installed.Count - measured.Count,
|
||
Failed = failed.Count,
|
||
Incomplete = incomplete,
|
||
// Skips leave the rate (they are not attempts); legacy rows stay in it.
|
||
FailureRate = TerminalFailureRatePct(failed.Count, installed.Count),
|
||
AvgDurationSeconds = measured.Count > 0 ? Math.Round(measured.Average(s => s.DurationSeconds), 0) : 0,
|
||
MaxDurationSeconds = measured.Count > 0 ? measured.Max(s => s.DurationSeconds) : 0,
|
||
MeasuredInstalls = measured.Count,
|
||
AvgDownloadBytes = measured.Count > 0 ? (long)measured.Average(s => s.DownloadBytes) : 0,
|
||
DoTotalBytesDownloaded = doG.TotalBytesDownloaded,
|
||
DoBytesFromPeers = doG.BytesFromPeers,
|
||
DoBytesFromCacheServer = doG.BytesFromCacheServer,
|
||
DoBytesFromHttp = doG.BytesFromHttp,
|
||
PeerOffloadPercent = OffloadPercent(doG.BytesFromPeers + doG.BytesFromCacheServer, doG.TotalBytesDownloaded),
|
||
TopFailureCodes = failed
|
||
.Where(f => !string.IsNullOrEmpty(f.FailureCode))
|
||
.GroupBy(f => f.FailureCode)
|
||
.OrderByDescending(fc => fc.Count())
|
||
.Take(3)
|
||
.Select(fc => new AppFailureCodeCount { Code = fc.Key, Count = fc.Count() })
|
||
.ToList()
|
||
};
|
||
}).ToList();
|
||
|
||
// Slowest ranking gates on MEASURED installs — an app whose durations were mostly
|
||
// unobserved must not rank as "fast" on a handful of zeros (audit §0.5).
|
||
var slowestApps = SelectSlowestApps(
|
||
appGroups, a => a.MeasuredInstalls, a => a.AvgDurationSeconds, minSamples: 3, take: 10);
|
||
|
||
var topFailingApps = appGroups
|
||
.Where(a => a.Failed > 0)
|
||
.OrderByDescending(a => a.Failed)
|
||
.ThenByDescending(a => a.FailureRate)
|
||
.Take(10)
|
||
.ToList();
|
||
|
||
var doAll = DoAggregator.Compute(summaryList);
|
||
|
||
return new AppMetricsResponse
|
||
{
|
||
Success = true,
|
||
TotalApps = appGroups.Count,
|
||
TotalInstalls = summaryList.Count,
|
||
TotalSkipped = appGroups.Sum(a => a.Skipped),
|
||
TotalUnmeasured = appGroups.Sum(a => a.Unmeasured),
|
||
TotalIncomplete = appGroups.Sum(a => a.Incomplete),
|
||
TotalCollisionExcluded = totalCollisionExcluded,
|
||
SlowestApps = slowestApps,
|
||
TopFailingApps = topFailingApps,
|
||
DeliveryOptimization = new AppMetricsDeliveryOptimization
|
||
{
|
||
TotalBytesDownloaded = doAll.TotalBytesDownloaded,
|
||
FromPeers = doAll.BytesFromPeers,
|
||
FromCacheServer = doAll.BytesFromCacheServer,
|
||
FromHttp = doAll.BytesFromHttp,
|
||
PeerOffloadPercent = OffloadPercent(doAll.BytesFromPeers + doAll.BytesFromCacheServer, doAll.TotalBytesDownloaded),
|
||
}
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds the complete Fleet Health response from the (already time-windowed) session list.
|
||
/// Single source of truth for the tenant (<c>metrics/fleet-health</c>) and global
|
||
/// (<c>global/metrics/fleet-health</c>) functions. Replaces the previous client-side path that
|
||
/// drained up to 200k raw sessions into the browser and ran these aggregations on the main
|
||
/// thread. Success rate follows the SLA convention: Succeeded / (Succeeded + Failed) — finished
|
||
/// enrollments only, so in-flight sessions and Incomplete (terminal, non-failure) never dilute
|
||
/// it. Duration stats (average, median, P90) count every non-in-progress session that carries a
|
||
/// positive duration (failures included); the cards lead with the median because enrollment
|
||
/// durations are heavily right-skewed and a handful of multi-hour outliers make the mean
|
||
/// meaningless as a "typical enrollment" answer.
|
||
/// </summary>
|
||
public static FleetHealthMetrics BuildFleetHealthPayload(IReadOnlyList<SessionSummary> sessions, int days)
|
||
{
|
||
int succeeded = 0, failed = 0, inProgress = 0, incomplete = 0;
|
||
var completedDurationSeconds = new List<double>();
|
||
|
||
foreach (var s in sessions)
|
||
{
|
||
switch (s.Status)
|
||
{
|
||
case SessionStatus.Succeeded: succeeded++; break;
|
||
case SessionStatus.Failed: failed++; break;
|
||
case SessionStatus.InProgress: inProgress++; break;
|
||
case SessionStatus.Incomplete: incomplete++; break;
|
||
}
|
||
|
||
if (s.Status != SessionStatus.InProgress && s.DurationSeconds is int d && d > 0)
|
||
completedDurationSeconds.Add(d);
|
||
}
|
||
|
||
completedDurationSeconds.Sort(); // Percentile() expects ascending order
|
||
int total = sessions.Count;
|
||
int finished = succeeded + failed;
|
||
var stats = new FleetHealthStats
|
||
{
|
||
Total = total,
|
||
Succeeded = succeeded,
|
||
Failed = failed,
|
||
InProgress = inProgress,
|
||
Incomplete = incomplete,
|
||
SuccessRate = finished > 0 ? Math.Round((double)succeeded / finished * 100, 1) : 0,
|
||
AvgDurationMinutes = completedDurationSeconds.Count > 0
|
||
? (int)Math.Round(completedDurationSeconds.Average() / 60.0, MidpointRounding.AwayFromZero)
|
||
: 0,
|
||
MedianDurationMinutes = SecondsToMinutes(Percentile(completedDurationSeconds, 50)),
|
||
P90DurationMinutes = SecondsToMinutes(Percentile(completedDurationSeconds, 90)),
|
||
};
|
||
|
||
return new FleetHealthMetrics
|
||
{
|
||
Success = true,
|
||
Days = days,
|
||
Stats = stats,
|
||
DailyData = BuildFleetDailyData(sessions, days),
|
||
FailureReasons = BuildFleetFailureReasons(sessions),
|
||
ModelHealth = BuildFleetModelHealth(sessions),
|
||
SlowestModels = BuildFleetSlowestModels(sessions),
|
||
TopFailingModels = BuildFleetTopFailingModels(sessions),
|
||
ComputedAt = DateTime.UtcNow,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// One point per day in the window, oldest-first, so every day renders even with zero
|
||
/// enrollments. Sessions are bucketed by their StartedAt calendar day, treated as UTC to
|
||
/// match the rest of the stats pipeline (see AggregateSessionStats' UTC-midnight boundary).
|
||
/// </summary>
|
||
private static List<FleetDailyPoint> BuildFleetDailyData(IReadOnlyList<SessionSummary> sessions, int days)
|
||
{
|
||
var buckets = new Dictionary<string, (int Success, int Failed)>();
|
||
foreach (var s in sessions)
|
||
{
|
||
if (s.Status != SessionStatus.Succeeded && s.Status != SessionStatus.Failed) continue;
|
||
var key = s.StartedAt.ToString("yyyy-MM-dd");
|
||
buckets.TryGetValue(key, out var cur);
|
||
if (s.Status == SessionStatus.Succeeded) cur.Success++;
|
||
else cur.Failed++;
|
||
buckets[key] = cur;
|
||
}
|
||
|
||
var result = new List<FleetDailyPoint>(days);
|
||
var today = DateTime.UtcNow.Date;
|
||
for (int i = days - 1; i >= 0; i--)
|
||
{
|
||
var date = today.AddDays(-i).ToString("yyyy-MM-dd");
|
||
buckets.TryGetValue(date, out var c);
|
||
result.Add(new FleetDailyPoint { Date = date, Success = c.Success, Failed = c.Failed });
|
||
}
|
||
return result;
|
||
}
|
||
|
||
private static List<FleetFailureReason> BuildFleetFailureReasons(IReadOnlyList<SessionSummary> sessions)
|
||
{
|
||
// Group near-identical messages by a 50-char prefix so variants collapse
|
||
// into one row, but keep the longest full reason as the display value. The
|
||
// UI truncates and expands it on demand, so the complete text must survive.
|
||
var groups = new Dictionary<string, (string Display, int Count)>();
|
||
foreach (var s in sessions)
|
||
{
|
||
if (s.Status != SessionStatus.Failed) continue;
|
||
var reason = string.IsNullOrEmpty(s.FailureReason) ? "Unknown" : s.FailureReason;
|
||
var key = reason.Length > 50 ? reason.Substring(0, 50) : reason;
|
||
if (groups.TryGetValue(key, out var g))
|
||
{
|
||
var display = reason.Length > g.Display.Length ? reason : g.Display;
|
||
groups[key] = (display, g.Count + 1);
|
||
}
|
||
else
|
||
{
|
||
groups[key] = (reason, 1);
|
||
}
|
||
}
|
||
return groups
|
||
.OrderByDescending(kv => kv.Value.Count)
|
||
.Take(5)
|
||
.Select(kv => new FleetFailureReason { Reason = kv.Value.Display, Count = kv.Value.Count })
|
||
.ToList();
|
||
}
|
||
|
||
private static List<FleetModelHealth> BuildFleetModelHealth(IReadOnlyList<SessionSummary> sessions)
|
||
{
|
||
var models = new Dictionary<ModelBucket, FleetModelHealth>();
|
||
foreach (var s in sessions)
|
||
{
|
||
var key = FleetModelKey(s);
|
||
if (!models.TryGetValue(key, out var m))
|
||
{
|
||
m = new FleetModelHealth { Manufacturer = key.Manufacturer, Model = key.Model, Label = key.Label };
|
||
models[key] = m;
|
||
}
|
||
m.Total++;
|
||
if (s.Status == SessionStatus.Succeeded) m.Succeeded++;
|
||
else if (s.Status == SessionStatus.Failed) m.Failed++;
|
||
}
|
||
return models.Values
|
||
.OrderByDescending(m => m.Total)
|
||
.Take(6)
|
||
.ToList();
|
||
}
|
||
|
||
private static List<FleetSlowModel> BuildFleetSlowestModels(IReadOnlyList<SessionSummary> sessions)
|
||
{
|
||
var acc = new Dictionary<ModelBucket, (long TotalDuration, int Count)>();
|
||
foreach (var s in sessions)
|
||
{
|
||
if (s.Status != SessionStatus.Succeeded) continue;
|
||
if (s.DurationSeconds is not int d || d <= 0) continue;
|
||
var key = FleetModelKey(s);
|
||
acc.TryGetValue(key, out var cur);
|
||
acc[key] = (cur.TotalDuration + d, cur.Count + 1);
|
||
}
|
||
return acc
|
||
.Select(kv => new FleetSlowModel
|
||
{
|
||
Manufacturer = kv.Key.Manufacturer,
|
||
Model = kv.Key.Model,
|
||
Label = kv.Key.Label,
|
||
AvgMinutes = (int)Math.Round((double)kv.Value.TotalDuration / kv.Value.Count / 60.0, MidpointRounding.AwayFromZero),
|
||
Count = kv.Value.Count,
|
||
})
|
||
.OrderByDescending(m => m.AvgMinutes)
|
||
.Take(5)
|
||
.ToList();
|
||
}
|
||
|
||
private static List<FleetFailingModel> BuildFleetTopFailingModels(IReadOnlyList<SessionSummary> sessions)
|
||
{
|
||
var acc = new Dictionary<ModelBucket, (int Failed, int Succeeded, int Total)>();
|
||
foreach (var s in sessions)
|
||
{
|
||
var key = FleetModelKey(s);
|
||
acc.TryGetValue(key, out var cur);
|
||
cur.Total++;
|
||
if (s.Status == SessionStatus.Failed) cur.Failed++;
|
||
else if (s.Status == SessionStatus.Succeeded) cur.Succeeded++;
|
||
acc[key] = cur;
|
||
}
|
||
// FailureRate over finished enrollments only (mirror of the success-rate convention);
|
||
// the Where(Failed > 0) guard also keeps the denominator non-zero.
|
||
return acc
|
||
.Where(kv => kv.Value.Failed > 0)
|
||
.Select(kv => new FleetFailingModel
|
||
{
|
||
Manufacturer = kv.Key.Manufacturer,
|
||
Model = kv.Key.Model,
|
||
Label = kv.Key.Label,
|
||
Failed = kv.Value.Failed,
|
||
Total = kv.Value.Total,
|
||
FailureRate = (int)Math.Round(
|
||
(double)kv.Value.Failed / (kv.Value.Failed + kv.Value.Succeeded) * 100, MidpointRounding.AwayFromZero),
|
||
})
|
||
.OrderByDescending(m => m.Failed)
|
||
.Take(5)
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Grouping key of the model buckets: the two session fields as stored (trimmed) plus the
|
||
/// display label "{Manufacturer} {Model}", or "Unknown" when both are blank. Grouping on the
|
||
/// pair keeps ("Contoso", "A B") and ("Contoso A", "B") apart even though their labels collide.
|
||
/// </summary>
|
||
private readonly record struct ModelBucket(string Manufacturer, string Model)
|
||
{
|
||
public string Label
|
||
{
|
||
get
|
||
{
|
||
var label = $"{Manufacturer} {Model}".Trim();
|
||
return string.IsNullOrEmpty(label) ? "Unknown" : label;
|
||
}
|
||
}
|
||
}
|
||
|
||
private static ModelBucket FleetModelKey(SessionSummary s)
|
||
=> new((s.Manufacturer ?? string.Empty).Trim(), (s.Model ?? string.Empty).Trim());
|
||
|
||
/// <summary>Share of total bytes (0-100, one decimal) not pulled from the CDN. 0 when no bytes.</summary>
|
||
private static double OffloadPercent(long offloaded, long total)
|
||
=> total > 0 ? Math.Round((double)offloaded / total * 100, 1) : 0;
|
||
|
||
/// <summary>
|
||
/// Calculates the nearest-rank percentile of an ascending-sorted value list,
|
||
/// rounded to one decimal place. Callers MUST pass values pre-sorted ascending.
|
||
/// Returns 0 for an empty list.
|
||
/// </summary>
|
||
/// <summary>Rounds a duration in seconds to whole minutes (away from zero, matching the avg).</summary>
|
||
public static int SecondsToMinutes(double seconds)
|
||
=> (int)Math.Round(seconds / 60.0, MidpointRounding.AwayFromZero);
|
||
|
||
public static double Percentile(List<double> sortedValues, int percentile)
|
||
{
|
||
if (sortedValues.Count == 0) return 0;
|
||
|
||
var index = (int)Math.Ceiling((percentile / 100.0) * sortedValues.Count) - 1;
|
||
index = Math.Max(0, Math.Min(index, sortedValues.Count - 1));
|
||
return Math.Round(sortedValues[index], 1);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ranks apps slowest-first by average duration, after dropping any app with fewer than
|
||
/// <paramref name="minSamples"/> successful installs. The sample floor stops a single N=1
|
||
/// install (often unfinished, or a legacy pre-clamp row) from dominating the ranking as an
|
||
/// artefact. Returns at most <paramref name="take"/> apps. Generic so both the tenant and
|
||
/// global app-metrics functions can rank their anonymous projections without duplication.
|
||
/// </summary>
|
||
public static List<T> SelectSlowestApps<T>(
|
||
IEnumerable<T> apps,
|
||
Func<T, int> succeededSelector,
|
||
Func<T, double> avgDurationSelector,
|
||
int minSamples,
|
||
int take)
|
||
{
|
||
return apps
|
||
.Where(a => succeededSelector(a) >= minSamples)
|
||
.OrderByDescending(avgDurationSelector)
|
||
.Take(take)
|
||
.ToList();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Wilson score interval (95 % default) for a binomial proportion — the F3 regression
|
||
/// radar's statistical primitive (insights spec §F3: deterministic, unit-pinned). Bounds
|
||
/// are clamped to [0,1]; n = 0 yields the uninformative (0,1) so a zero-denominator
|
||
/// side can never claim separation.
|
||
/// </summary>
|
||
public static (double Lower, double Upper) WilsonInterval(int successes, int trials, double z = 1.96)
|
||
{
|
||
if (trials <= 0) return (0.0, 1.0);
|
||
var n = (double)trials;
|
||
var p = Math.Clamp((double)successes / n, 0.0, 1.0);
|
||
var z2 = z * z;
|
||
var denominator = 1.0 + z2 / n;
|
||
var center = (p + z2 / (2.0 * n)) / denominator;
|
||
var half = (z / denominator) * Math.Sqrt(p * (1.0 - p) / n + z2 / (4.0 * n * n));
|
||
return (Math.Max(0.0, center - half), Math.Min(1.0, center + half));
|
||
}
|
||
|
||
/// <summary>
|
||
/// One-sided two-proportion separation for a rate INCREASE: true when the current
|
||
/// window's Wilson lower bound lies strictly above the baseline's Wilson upper bound —
|
||
/// the intervals are disjoint in the regression direction, so the lift is statistically
|
||
/// real rather than small-n noise (insights spec §F3 detection gate 3).
|
||
/// </summary>
|
||
public static bool RateIncreaseSeparated(
|
||
int windowHits, int windowTrials, int baselineHits, int baselineTrials, double z = 1.96)
|
||
{
|
||
var window = WilsonInterval(windowHits, windowTrials, z);
|
||
var baseline = WilsonInterval(baselineHits, baselineTrials, z);
|
||
return window.Lower > baseline.Upper;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Per-tenant session status tally. Every status maps to exactly one bucket, so the component
|
||
/// counts always reconcile to <see cref="Total"/> by construction: Pending and Stalled — which
|
||
/// were previously counted in the total but in no bucket, silently widening the gap — now have
|
||
/// their own buckets, and any unrecognised status (incl. Unknown) lands in <see cref="Other"/>.
|
||
/// </summary>
|
||
public readonly record struct SessionStatusBuckets(
|
||
int Total, int Succeeded, int Failed, int InProgress, int Pending, int Stalled,
|
||
int AwaitingUser, int Incomplete, int Other)
|
||
{
|
||
/// <summary>Returns a new tally with <paramref name="status"/> folded in.</summary>
|
||
public SessionStatusBuckets Add(string? status)
|
||
{
|
||
var total = Total + 1;
|
||
var succeeded = Succeeded + (status == "Succeeded" ? 1 : 0);
|
||
var failed = Failed + (status == "Failed" ? 1 : 0);
|
||
var inProgress = InProgress + (status == "InProgress" ? 1 : 0);
|
||
var pending = Pending + (status == "Pending" ? 1 : 0);
|
||
var stalled = Stalled + (status == "Stalled" ? 1 : 0);
|
||
// AwaitingUser (non-terminal, Device Setup done) and Incomplete (terminal, non-failure) get
|
||
// their own buckets so they no longer hide in Other and never inflate the failure count.
|
||
var awaitingUser = AwaitingUser + (status == "AwaitingUser" ? 1 : 0);
|
||
var incomplete = Incomplete + (status == "Incomplete" ? 1 : 0);
|
||
var other = total - (succeeded + failed + inProgress + pending + stalled + awaitingUser + incomplete);
|
||
return new SessionStatusBuckets(total, succeeded, failed, inProgress, pending, stalled, awaitingUser, incomplete, other);
|
||
}
|
||
}
|